A transparent, in-memory, streaming write-on-update JavaScript database for Small Web applications that persists to a JavaScript transaction log.
A transparent, in-memory, streaming write-on-update JavaScript database for Small Web applications that persists to a JavaScript transaction log.
A data layer for simple Small Web sites for basic public (e.g., anonymous comments on articles) or configuration data. Built for use in Site.js.
Not to farm people for their data. Surveillance capitalists can jog on now.
Transparent: if you know how to work with arrays and objects and call methods in JavaScript, you already know how to use JSDB? It’s not called JavaScript Database for nothing.
Automatic: it just works. No configuration.
100% code coverage: meticulously tested. Note that this does not mean it is bug free ;)
Small Data: this is for small data, not Big Data™.
For Node.js: will not work in the browser. (Although data tables are plain JavaScript files and can be loaded in the browser.)
Runs on untrusted nodes: this is for data kept on untrusted nodes (servers). Use it judiciously if you must for public data, configuration data, etc. If you want to store personal data or model human communication, consider end-to-end encrypted and peer-to-peer replicating data structures instead to protect privacy and freedom of speech. Keep an eye on the work taking place around the Hypercore Protocol.
In-memory: all data is kept in memory and, without tweaks, cannot exceed 1.4GB in size. While JSDB will work with large datasets, that’s not its primary purpose and it’s definitely not here to help you farm people for their data, so please don’t use it for that. (If that’s what you want, quite literally every other database out there is for your use case so please use one of those instead.)
Streaming writes on update: writes are streamed to disk to an append-only transaction log as JavaScript statements and are both quick (in the single-digit miliseconds region on a development laptop with an SSD drive) and as safe as we can make them (synchronous at the kernel level).
No schema, no migrations: again, this is meant to be a very simple persistence, query, and observation layer for local server-side data. If you want schemas and migrations, take a look at nearly every other database out there.
Note: the limitations are also features, not bugs. This is a focused tool for a specific purpose. While feature requests are welcome, I do not foresee extending its application scope.
Small Technology Foundation is a tiny, independent not-for-profit.
We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.
Currently, you need to clone or install from this repo as this is a work-in-progress and no releases have been made yet to npm.
npm i github:small-tech/jsdb
Here’s a quick example to whet your appetite:
const JSDB = require('@small-tech/jsdb')// Create your database in the test folder. // (This is where your JSDF files – “tables” – will be saved.) // const db = JSDB.open('db')
// Create db/people.js table with some initial data if it // doesn’t already exist. if (!db.people) { db.people = [ {name: 'Aral', age: 43}, {name: 'Laura', age: 34} ]
// Correct Laura’s age. (This will automatically update db/people.js) db.people[1].age = 33
// Add Oskar to the family. (This will automatically update db/people.js) db.people.push({name: 'Oskar', age: 8})
// Update Oskar’s name to use his nickname. (This will automatically update db/people.js) db.people[2].name = 'Osky' }
After running the above script, take a look at the resulting database table in the
./db/people.jsfile.
JSDB tables are written into JavaScript Data Format (JSDF) files. A JSDF file is a plain JavaScript file that comprises an append-only transaction log that creates the table in memory. For our example, it looks like this:
globalThis._ = [ { name: `Aral`, age: 43 }, { name: `Laura`, age: 34 } ]; (function () { if (typeof define === 'function' && define.amd) { define([], globalThis._); } else if (typeof mo; _[1]['age'] = 33; _[2] = { name: `Oskar`, age: 8 }; _[2]['name'] = `Osky`;
Given that a JSDF file is just JavaScript.
The first line is a single assignment of all the data that existed in the table when it was created or last loaded.
The second line is a UMD-style declaration.
Any changes to the table within the last session that it was open are written, one statement per line, starting with the third line.
Since the format contains a UMD-style declaration, you can simply
require()a JSDF file as a module in Node.js or even load it using a script tag.
For example, create an index.html file with the following content in the same folder as the other script and serve it locally using Site.js and you will see the data printed out in your browser:
People
Just because it’s JavaScript, it doesn’t mean that you can throw anything into JSDB and expect it to work.
Number
Boolean
String
Object
Array
Date
Symbol
Additionally,
nulland
undefinedvalues will be persisted as-is.
Strings are automatically sanitised to escape backticks, backslashes, and template placeholder tokens to avoid arbitrary code execution via JavaScript injection attacks.
The relevant areas in the codebase are linked to below.
If you notice anything we’ve overlooked or if you have suggestions for improvements, please open an issue.
Custom data types (instances of your own classes) are also supported.
During serialisation, class information for custom data types will be persisted.
During deserialisation, if the class in question exists in memory, your object will be correctly initialised as an instance of that class. If the class does not exist in memory, your object will be initialised as a plain JavaScript object.
e.g.,
const JSDB = require('@small-tech/jsdb')class Person { constructor (name = 'Jane Doe') { this.name = name } introduceYourself () { console.log(
Hello, I’m ${this.name}.
) } }const db = JSDB.open('db')
// Initialise the people table if it doesn’t already exist. if (!db.people) { db.people = [ new Person('Aral'), new Person('Laura') ] }
// Will always print out “Hello, I’m Laura.” // (On the first run and on subsequent runs when the objects are loaded from disk.) db.people[1].introduceYourself()
If you look in the created
db/people.jsfile, this time you’ll see:
globalThis._ = [ Object.create(typeof Person === 'function' ? Person.prototype : {}, Object.getOwnPropertyDescriptors({ name: `Aral` })), Object.create(typeof Person === 'function' ? Person.prototype : {}, Object.getOwnPropertyDescriptors({ name: `Laura` })) ]; (function () { if (typeof define === 'function' && define.amd) { define([], globalThis._); } else if (typeof module === 'object' && module.exports) { module.exports = globalThis._ } else { globalThis.people = globalThis._ } })();
If you were to load the database in an environment where the
Personclass does not exist, you will get a regular object back.
To test this, you can run the following code:
const JSDB = require('@small-tech/jsdb') const db = JSDB.open('db')// Prints out { name: 'Laura' } console.log(db.people[1])
You can find these examples in the
examples/custom-data-typesfolder of the source code.
If you try to add an instance of an unsupported data type to a JSDB table, you will get a
TypeError.
The following data types are currently unsupported but might be supported in the future:
Map(and
WeakMap)
Set(and
WeakSet)
ArrayBuffer,
Float32Array,
Float64Array,
Int8Array,
Int16Array,
Int32Array,
TypedArray,
Uint8Array,
Uint16Array,
Uint32Array, and
Uint8ClampedArray)
The following intrinsic objects are not supported as they don’t make sense to support:
DataView,
Function,
Generator,
Promise,
Proxy,
RegExp)
Error,
EvalError,
RangeError,
ReferenceError,
SyntaxError,
TypeError, and
URIError)
JSDF is not a data exchange format.
Since JSDF is made up of JavaScript code that is evaluated at run time, you must only load JSDF files from domains that you own and control and have a secure connection to.
Do not load in JSDF files from third parties.
If you need a data exchange format, use JSON.
Rule of thumb:
In the browser-based example, above, you loaded the data in directly. When you do that, of course, you are not running it inside JSDB so you cannot update the data or use the JavaScript Query Language (JSQL) to query it.
To test out JSQL, open a Node.js command-line interface (run
node) from the directory that your scripts are in and enter the following commands:
const JSDB = require('@small-tech/jsdb')// This will load test database with the people table we created earlier. const db = JSDB.open('db')
// Let’s carry out a query that should find us Osky. console.log(db.people.where('age').isLessThan(21).get())
Note that you can only run queries on arrays. Attempting to run them on plain or custom objects (that are not subclasses of
Array) will result in a
TypeError. Furthermore, queries only make sense when used on arrays of objects. Running a query on an array of simple data types will not throw an error but will return an empty result set.
For details, see the JSQL Reference section.
When you load in a JSDB table, by default JSDB will compact the JSDF file.
Compaction is important for two reasons; during compaction:
Compaction may thus also reduce the size of your tables.
Compaction is a relatively fast process but it does get uniformly slower as the size of your database grows (it has O(N) time complexity as the whole database is recreated).
You do have the option to override the default behaviour and keep all history. You might want to do this, for example, if you’re creating a web app that lets you create a drawing and you want to play the drawing back stroke by stroke, etc.
Now that you’ve loaded the file back, look at the
./db/people.jsJSDF file again to see how it looks after compaction:
globalThis._ = [ { name: `Aral`, age: 43 }, { name: `Laura`, age: 33 }, { name: `Osky`, age: 8 } ]; (function () { if (typeof define === 'function' && define.amd) { define([], globalThis._); } else if (typeof module === 'object' && module.exports) { module.exports = globalThis._ } else { globalThis.people = globalThis._ } })();
Ah, that is neater. Laura’s record is created with the correct age and Oskar’s name is set to its final value from the outset. And it all happens on the first line, in a single assignment. Any new changes will, just as before, be added starting with the third line.
(You can find these examples in the
examples/basicfolder of the source code.)
Your database tables will be automatically closed if you exit your script. However, there might be times when you want to manually close a database (for example, to reopen it with different settings, etc.) In that case, you can call the asynchronous
close()method on the database proxy.
Here’s what you’d do to close the database in the above example:
async main () { // … 🠑 the earlier code from the example, above.await db.close()
// The database and all of its tables are now closed. // It is now safe (and allowed) to reopen it. }
main()
As mentioned earlier, JSDB writes out its tables as append-only logs of JavaScript statements in what we call JavaScript Data Format (JSDF). This is not the same as JavaScript Object Notation (JSON).
JSON is not a good format for a database but it is excellent – not to mention ubiquitous – for its original use case of data exchange. You can easily find or export datasets in JSON format. And using them in JSDB is effortless. Here’s an example that you can find in the
examples/jsonfolder of the source code:
Given a JSON data file of spoken languages by country in the following format:
[ { "country": "Aruba", "languages": [ "Dutch", "English", "Papiamento", "Spanish" ] }, { "etc.": "…" } ]
The following code will load in the file, populate a JSDB table with it, and perform a query on it:
const fs = require('fs') const JSDB = require('@small-tech/jsdb')const db = JSDB.open('db')
// If the data has not been populated yet, populate it. if (!db.countries) { const countries = JSON.parse(fs.readFileSync('./countries.json', 'utf-8')) db.countries = countries }
// Query the data. const countriesThatSpeakKurdish = db.countries.where('languages').includes('Kurdish').get()
console.log(countriesThatSpeakKurdish)
When you run it, you should see the following result:
[ { country: 'Iran', languages: [ 'Arabic', 'Azerbaijani', 'Bakhtyari', 'Balochi', 'Gilaki', 'Kurdish', 'Luri', 'Mazandarani', 'Persian', 'Turkmenian' ] }, { country: 'Iraq', languages: [ 'Arabic', 'Assyrian', 'Azerbaijani', 'Kurdish', 'Persian' ] }, { country: 'Syria', languages: [ 'Arabic', 'Kurdish' ] }, { country: 'Turkey', languages: [ 'Arabic', 'Kurdish', 'Turkish' ] } ]
The code for this example is in the
examples/jsonfolder of the source code.
Here are a couple of facts to dispel the magic behind what’s going on:
When you open a database, JSDB loads in any
.jsfiles it can find in your database directory. Doing so creates the data structures defined in those files in memory. Alongside, JSDB also creates a structure of proxies that mirrors the data structure and traps (captures) calls to get, set, or delete values. Every time you set or delete a value, the corresponding JavaScript statement is appended to your table on disk.
By calling the
where()or
whereIsTrue()methods, you start a query. Queries help you search for specific bits of data. They are implemented using the get traps in the proxy.
Given that a core goal for JSDB is to be transparent, you will mostly feel like you’re working with regular JavaScript collections (objects and arrays) instead of a database. That said, there are a couple of gotchas and limitations that arise from the use of proxies and the impedance mismatch between synchronous data manipulation in JavaScript and the asynchronous nature of file handling:
You can only have one copy of a database open at one time. Given that tables are append-only logs, having multiple streams writing to them would corrupt your tables. The JSDB class enforces this by forcing you to use the
open()factory method to create or load in your databases.
You cannot reassign a value to your tables without first deleting them. Since assignment is a synchronous action and since we cannot safely replace the existing table on disk with a different one synchronously, you must first call the asynchronous
delete()method on a table instance before assigning a new value for it on the database, thereby creating a new table.
async main () { // … 🠑 the earlier code from the example, above.await db.people.delete() // The people table is now deleted and we can recreate it. // This is OK. db.people = [ {name: 'Ed Snowden', age: 37} ] // This is NOT OK. try { db.people = [ {name: 'Someone else', age: 100} ] } catch (error) { console.log('This throws as we haven’t deleted the table first.') }
}
main()
There are certain reserved words you cannot use in your data. This is a trade-off between usability and polluting the mirrored proxy structure. JSDB strives to keep reserved words to a minimum.
This is the full list:
Reserved words | |
---|---|
As table name | close |
Property names in data | where , whereIsTrue , addListener , removeListener , delete , __table__ |
Note: You can use the __table__
property from any level of your data to get a reference to the table instance (JSTable
instance) that it belongs to. This is mostly for internal use but it’s there if you need it.
You can listen for the following events on tables:
| Event name | Description | | ---------- | ------------------------------------- | | persist | The table has been persisted to disk. | | delete | The table has been deleted from disk. |
The following handler will get called whenever a change is persisted to disk for the
peopletable:
db.people.addListener('persist', (table, change) => { console.log(`Table ${table.tableName} persisted change ${change.replace('\n', '')} to disk.`) })
The examples in the reference all use the following random dataset. Note, I know nothing about cars, the tags are also arbitrary. Don’t @ me ;)
const cars = [ { make: "Subaru", model: "Loyale", year: 1991, colour: "Fuscia", tags: ['fun', 'sporty'] }, { make: "Chevrolet", model: "Suburban 1500", year: 2004, colour: "Turquoise", tags: ['regal', 'expensive'] }, { make: "Honda", model: "Element", year: 2004, colour: "Orange", tags: ['fun', 'affordable'] }, { make: "Subaru", model: "Impreza", year: 2011, colour: "Crimson", tags: ['sporty', 'expensive']}, { make: "Hyundai", model: "Santa Fe", year: 2009, colour: "Turquoise", tags: ['sensible', 'affordable'] }, { make: "Toyota", model: "Avalon", year: 2005, colour: "Khaki", tags: ['fun', 'affordable']}, { make: "Mercedes-Benz", model: "600SEL", year: 1992, colour: "Crimson", tags: ['regal', 'expensive', 'fun']}, { make: "Jaguar", model: "XJ Series", year: 2004, colour: "Red", tags: ['fun', 'expensive', 'sporty']}, { make: "Isuzu", model: "Hombre Space", year: 2000, colour: "Yellow", tags: ['sporty']}, { make: "Lexus", model: "LX", year: 1997, colour: "Indigo", tags: ['regal', 'expensive', 'AMAZING'] } ]
where()method)
const carsMadeIn1991 = db.cars.where('year').is(1991).get()
The
where()method starts a query.
You call it on a table reference. It takes a property name (string) as its only argument and returns a query instance.
On the returned query instance, you can call various operators like
is()or
startsWith().
Finally, to invoke the query you use one one of the invocation methods:
get(),
getFirst(), or
getLast().
Idiomatically, we chain the operator and invocation calls to the
wherecall and write our queries out in a single line as shown above. However, you can split the three parts up, should you so wish. Here’s such an example, for academic purposes.
This starts the query and returns an incomplete query object:
const incompleteCarYearQuery = db.cars.where('year')
Once you call an operator on a query, it is considered complete:
const completeCarYearQuery = incompleteCarYearQuery.is(1991)
To execute a completed query, you can use one of the invocation methods:
get(),
getFirst(), or
getLast().
Note that
get()returns an array of results (which might be an empty array) while
getFirst()and
getLast()return a single result (which may be
undefined).
const resultOfCarYearQuery = completeCarYearQuery.get()
Here are the three parts of a query shown together:
const incompleteCarYearQuery = db.cars.where('year') const completeCarYearQuery = incompleteCarYearQuery.is(1991) const resultOfCarYearQuery = completeCarYearQuery.get()
Again, idiomatically, we chain the operator and invocation calls to the
where()call and write our queries out in a single line like this:
const carsMadeIn1991 = db.cars.where('year').is(1991).get()
and()and
or())
You can chain conditions onto a query using the connectives
and()and
or(). Using a connective transforms a completed query back into an incomplete query awaiting an operator. e.g.,
const veryOldOrOrangeCars = db.cars.where('year').isLessThan(2000).or('colour').is('Orange').get()
const carsThatAreFunAndSporty = db.cars.where('tags').includes('fun').and('tags').includes('sporty').get()
[ { make: "Subaru", model: "Loyale", year: 1991, colour: "Fuscia", tags: ['fun', 'sporty'] }, { make: "Jaguar", model: "XJ Series", year: 2004, colour: "Red", tags: ['fun', 'expensive', 'sporty']}, ]
whereIsTrue())
For more complex queries – for example, if you need to include parenthetical grouping – you can compose your JSQL by hand. To do so, you call the
whereIsTrue()method on a table instead of the
where()method and you pass it a full JSQL query string. A completed query is returned.
When writing your custom JSQL query, prefix property names with
valueOf..
Note that custom queries are inherently less safe as you are responsible for sanitising input at the application level to avoid leaking sensitive data. (Basic sanitisation to avoid arbitrary code execution is handled for you by JSDB). Make sure you read through the Security considerations with queries](#security-considerations-with-queries) section if you’re going to use custom queries.
const customQueryResult = db.cars.whereIsTrue(`(valueOf.tags.includes('fun') && valueOf.tags.includes('affordable')) || (valueOf.tags.includes('regal') && valueOf.tags.includes('expensive'))`).get()
[ { make: 'Chevrolet', model: 'Suburban 1500', year: 2004, colour: 'Turquoise', tags: [ 'regal', 'expensive' ] }, { make: 'Honda', model: 'Element', year: 2004, colour: 'Orange', tags: [ 'fun', 'affordable' ] }, { make: 'Toyota', model: 'Avalon', year: 2005, colour: 'Khaki', tags: [ 'fun', 'affordable' ] }, { make: 'Mercedes-Benz', model: '600SEL', year: 1992, colour: 'Crimson', tags: [ 'regal', 'expensive', 'fun' ] }, { make: 'Lexus', model: 'LX', year: 1997, colour: 'Indigo', tags: [ 'regal', 'expensive', 'AMAZING' ] } ]
is(),
isEqualTo(),
equals()
isNot(),
doesNotEqual()
isGreaterThan()
isGreaterThanOrEqualTo()
isLessThan()
isLessThanOrEqualTo()
Note: operators listed on the same line are aliases and may be used interchangeably (e.g.,
isNot()and
doesNotEqual()).
const carWhereYearIs1991 = db.cars.where('year').is(1991).getFirst()
{ make: "Subaru", model: "Loyale", year: 1991, colour: "Fuscia", tags: ['fun', 'sporty'] }
const carsWhereYearIsNot1991 = db.cars.where('year').isNot(1991).get()
[ { make: "Chevrolet", model: "Suburban 1500", year: 2004, colour: "Turquoise", tags: ['regal', 'expensive'] }, { make: "Honda", model: "Element", year: 2004, colour: "Orange", tags: ['fun', 'affordable'] }, { make: "Subaru", model: "Impreza", year: 2011, colour: "Crimson", tags: ['sporty', 'expensive']}, { make: "Hyundai", model: "Santa Fe", year: 2009, colour: "Turquoise", tags: ['sensible', 'affordable'] }, { make: "Toyota", model: "Avalon", year: 2005, colour: "Khaki", tags: ['fun', 'affordable'] }, { make: "Mercedes-Benz", model: "600SEL", year: 1992, colour: "Crimson", tags: ['regal', 'expensive', 'fun'] }, { make: "Jaguar", model: "XJ Series", year: 2004, colour: "Red", tags: ['fun', 'expensive', 'sporty'] }, { make: "Isuzu", model: "Hombre Space", year: 2000, colour: "Yellow", tags: ['sporty'] }, { make: "Lexus", model: "LX", year: 1997, colour: "Indigo", tags: ['regal', 'expensive', 'AMAZING'] } ]
Note how
getFirst()returns the first item (in this case, an object) whereas
get()returns the whole array of results.
The other relational operators work the same way and as expected.
startsWith()
endsWith()
includes()
startsWithCaseInsensitive()
endsWithCaseInsensitive()
includesCaseInsensitive()
The string subset comparison operators carry out case sensitive string subset comparisons. They also have case insensitive versions that you can use.
includes()and
includesCaseInsensitive())
const result1 = db.cars.where('make').includes('su').get() const result2 = db.cars.where('make').includes('SU').get() const result3 = db.cars.where('make').includesCaseInsensitive('SU')
[ { make: "Isuzu", model: "Hombre Space", year: 2000, colour: "Yellow", tags: ['sporty']} ]
Since
includes()is case sensitive, the string
'su' matches only the make
Isuzu.
[]
Again, since
includes()is case sensitive, the string
'SU' doesn’t match the make of any of the entries.
[ { make: "Subaru", model: "Impreza", year: 2011, colour: "Crimson", tags: ['sporty', 'expensive'] }, { make: "Isuzu", model: "Hombre Space", year: 2000, colour: "Yellow", tags: ['sporty'] } ]
Here,
includesCaseInsensitive('SU')matches both the
Subaruand
Isuzumakes due to the case-insensitive string comparison.
includes()
The
includes()array inclusion check operator can also be used to check for the existence of an object (or scalar value) in an array.
Note that the
includesCaseInsensitive()string operator cannot be used for this purpose and will throw an error if you try.
includes()array inclusion check):
const carsThatAreRegal = db.cars.where('tags').includes('regal').get()
includes()array inclusion check)
[ { make: "Chevrolet", model: "Suburban 1500", year: 2004, colour: "Turquoise", tags: ['regal', 'expensive'] }, { make: "Mercedes-Benz", model: "600SEL", year: 1992, colour: "Crimson", tags: ['regal', 'expensive', 'fun']}, { make: "Lexus", model: "LX", year: 1997, colour: "Indigo", tags: ['regal', 'expensive', 'AMAZING'] } ]
JSDB (as of version 1.1.0), attempts to carry out basic sanitisation of your queries for you to avoid Little Bobby Tables.
That said, you should still sanitise your queries at the application level, if you’re using custom queries via
whereIsTrue(). Basic sanitisation will protect you from arbitrary code execution but it will not protect you from, for example, someone passing
|| valueOf.admin === trueto attempt to access private information. You should be vigilant in your sanitisation when using
whereIsTrue()and stick to using
where()whenever possible.
The current sanitisation strategy is two-fold and is executed at time of query execution:
- Semi-colon (`;`) - Backslash (`\`) - Backtick (`` ` ``) - Plus sign (`+`) - Dollar sign (`$`) - Curly brackets (`{}`)Reasoning: remove symbols that could be used to create valid code so that if our sieve (see below) doesn’t catch an attempt, the code will throw an error when executed, which we can catch and handle.
During query execution, if the query throws (due to an injection attempt that was neutralised at Step 1 but made it through the sieve), we simply catch the error and return an empty result set.
The relevant areas in the codebase are linked to below.
If you notice anything we’ve overlooked or if you have suggestions for improvements, please open an issue.
node --max-old-space-size=8192 why-is-my-database-so-large-i-hope-im-not-doing-anything-shady.js
The reason JSDB is fast is because it keeps the whole database in memory. Also, to provide a transparent persistence and query API, it maintains a parallel object structure of proxies. This means that the amount of memory used will be multiples of the size of your database on disk and exhibits O(N) memory complexity.
Initial load time and full table write/compaction both exhibit O(N) time complexity.
For example, here’s just one sample from a development laptop using the simple performance example in the
examples/performancefolder of the source code which creates random records that are around ~2KB in size each:
| Number of records | Table size on disk | Memory used | Initial load time | Full table write/compaction time | | ----------------- | ------------------ | ----------- | ----------------- | -------------------------------- | | 1,000 | 2.5MB | 15.8MB | 85ms | 45ms | | 10,000 | 25MB | 121.4MB | 845ms | 400ms | | 100,000 | 250MB | 1.2GB | 11 seconds | 4.9 seconds |
(The baseline app used about 14.6MB without any table in memory. The memory used column subtracts that from the total reported memory so as not to skew the smaller dataset results.)
Note: For tables > 500GB, compaction is turned off and a line-by-line streaming load strategy is implemented. If you foresee your tables being this large, you (a) are probably doing something nasty (and won’t mind me pointing it out if you’re not) and (b) should turn off compaction from the start for best performance. Keeping compaction off from the start will decrease initial table load times. Again, don’t use this to invade people’s privacy or profile them.
Please open an issue before starting to work on pull requests.
npm i
npm test
For code coverage, run
npm run coverage.
Small Technology Foundation is a tiny, independent not-for-profit.
We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.
© 2020 Aral Balkan, Small Technology Foundation.