Skip to main content

Quick Start 🔥

FireSageJS 烈火贤JS

Created by tylim88 License dependency count package size github action code coverage GitHub issues vulnerabilities

Surgical Precision Type Safe For Realtime Database Web, Write RTDB Code That Stands The Test Of Time

Immaculate, One of a Kind, Unorthodox, Craftsmanship, Art

In The Relentless Pursuit Of Correctness


support @firebase/rules-unit-testing and emulator

Installation

npm i firebase firesagejs
npm i -D @firebase/rules-unit-testing typescript
caution

require Typescript 4.8.2+, Firebase 9+ and @firebase/rules-unit-testing 2+

info

The library size on disk is 300KB, most of the code are type definitions and comments, the runtime code is tiny after minified and gzipped.

tip

The documentation is far from finish and will never be because most of contents are just duplication of the official SDK.

We designed this library to look almost identical to the official SDK to lower the learning curve.

So if you cannot find what you want here, high chances it is in the official RTDB references.

Any help wth the documentation is welcomed.

Typescript Configuration

tsconfig.json
{
// ...other configs
"compilerOptions": {
// ...other options
"strict": true, // compulsory
// below options are optional, be sure to google what they do!
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
tip

Every detail or rule stated in the code below may sound like runtime validation or something you have to memorize, but they are in fact type level validation.

Unless stated explicitly, everything you read is type level validation and you do not need to memorize them.

All paths, keys, values and returns are narrowly typed, data snapshots are recursively typed.

Define The Meta Type

Meta Type is your single source of truth.

defineMetaType.ts
import {
MetaTypeCreator,
ServerTimestamp,
PushAbleOnly, // field type, special index signature, is Record<string, T> under the hood
NumericKeyRecord, // field type, special index signature, is Record<`${number}`, T> under the hood
Removable, // field type, utility
} from 'firesagejs'

export type Example = MetaTypeCreator<{
a: 1 | 90 | 3700
b:
| {
c: boolean | Removable // only Removable node can be removed via `remove` function.
d: {
e: ServerTimestamp // ServerTimestamp node write as serverTimestamp and read as number, write as number is not possible.
}
}
| Removable
f: Record<string, 'a' | 'b' | 'c'> // normal index signature, any key related argurment operate on this node only accept non-numeric string
g: PushAbleOnly<{ h: number; j: { k: boolean } }> // the child of PushAbleOnly node can only be created via `push` function, any key related argurment operate on this node only accept non-numeric string
i: NumericKeyRecord<string> // Firesage replaces all numeric key node { 1:'a', 2:'b', 3:'c' } with error message, use NumericKeyRecord instead. Any the key related argurment operate on this node only accept numeric string
}>

// note: alphanumeric is considered as non-numeric string(string that has any two or more combination of number, alphabet and special characters).
// primitive type can union with primitive type and Removable
// non-primitive type cannot union with any other type(except Removable), otherwise Firesage will replaces the type with an error message.
// FireSage able to deduce the correct type even you:
// 1. nest Record, PushAbleOnly and NumericKeyRecord into each other, eg NumericKeyRecord<Record<PushAbleOnly<{ a: number }>>>
// 2. union Removable in any way, eg { g : PushAbleOnly<{ h: number | Removable } | Removable> } | Removable

Create Ref

createRef.ts
import { Example } from './defineMetaType'
import { initializeApp } from 'firebase/app'
import { getDatabase, createRef } from 'firesagejs'

const app = initializeApp({
projectId: '### PROJECT ID ###',
})

export const db = getDatabase() // or getDatabase(app)

export const exampleRef = createRef<Example>(db) // this is your firesage ref

Ref

import { exampleRef } from './createRef'

exampleRef() // point to node 'root', type is --too long, skipped--
exampleRef('b') // point to node 'b', type is --too long, skipped--
exampleRef('b/d/e') // point to node 'b/d/e', write type is ServerTimestamp, compare and read type is number
exampleRef('g') // point to node 'g', type is Record<string, { h: number; j: { k: boolean } }>
exampleRef('g/abc') // point to node'g/abc', type is { h: number; j: { k: boolean } }
exampleRef('i') // point to node 'i', type is Record<`${number}`, string>
exampleRef('i/123') // point to node 'i/123', type is string

Operations

import { exampleRef } from './createRef'
import {
set,
get,
update,
serverTimestamp,
remove,
push,
increment,
} from 'firesagejs'
//
;async () => {
// the type of node 'a' is numeric literal 1 | 90 | 3700, it is not possible to use `increment` on it
await set(exampleRef('a'), 1)

// 1st array is relative child paths
// 2nd array is values, it will try to match the path by index, 'b/c' is boolean, 'b/d/e' is server timestamp
// the length of values will match the length of paths
// finally it scans all paths to find out whether a child path is also a child of another path (to prevent runtime exception).
await update(exampleRef(), ['b/c', 'b/d/e'], [true, serverTimestamp()])

// 'get' returns fully typed snapshot
const snapshot = await get(exampleRef('f'))
const val = snapshot.val() // value type is what defined in MetaType union with null, in this case it is Record<string, 'a' | 'b' | 'c'> | null
const exists = snapshot.exists() // boolean
const size = snapshot.size // number
const hasChild = snapshot.hasChild('k') // boolean, type of argument is what defined in MetaType, in this case it is `string` because 'f' is Record<string, 'a' | 'b' | 'c'>
const hasChildren = snapshot.hasChildren() // boolean
const json = snapshot.toJSON() // object, we have to type cast it
snapshot.forEach((child, index) => {
// the official SDK does not provide index, but we get one here, for free
// like official SDK, child is also snapshot and can access forEach, val, key, size, exists, child, hasChild, hasChildren, toJSON
// child is recursively typed, hooray!!
})

// only node with Removable type can be removed and b/c is boolean | Removable
await remove(exampleRef('b/c'))

// only PushAbleOnly node can be pushed and g is PushAbleOnly<{ h: number }>
// PushAbleOnly node cannot be set or updated
// Child of PushAbleOnly node can be set or updated, as long as the child itself is not PushAbleOnly
await push(exampleRef('g'), { h: increment(1), j: { k: true } })
}

Query

import { exampleRef } from './createRef'
import {
get,
orderByChild,
orderByKey,
orderByValue,
startAt,
startAfter,
endAt,
endBefore,
equalTo,
limitToFirst,
limitToLast,
query,
} from 'firesagejs'

// only node of type Record<string, T>, PushAbleOnly<T> or NumericKeyRecord<T> can be queried
// orderBy clause must exist in order to use cursors (qualTo, endAt... etc etc)
// there can be only one orderBy clause (to prevent runtime exception)
// the type of cursor 1st argument must be at most string | boolean | number | null
// the 2nd argument of cursor is optional except when order by key
// any combination of cursor below will trigger type error(to prevent runtime exception):
// 1. startAt + startAfter
// 2. endAt + endBefore
// 3. equalTo + any other cursor
// 4. any cursor + itself
;async () => {
const snapshot = await get(
query(
exampleRef('i'),
orderByValue(),
// only Record<string, primitive>, PushAbleOnly<primitive> or NumericKeyRecord<primitive> can be ordered by value
// the type of node 'i' is NumericKeyRecord<string>, hence the 1st argument type is string
// and the 2nd argument type is numeric string because the key of NumericKeyRecord is `${number}`
startAt('abc', '1'),
// limit accept numeric literal that is not negative or float
// limit also accept the wide type 'number'(for those who rely on runtime value)
limitToFirst(4)
)
)
const snapshot2 = await get(
query(
exampleRef('f'),
orderByKey(),
// when order by key:
// there would be no 2nd argument
// the 1st argument type is non-numeric string or numeric string
// in this case it is non numeric string because type of 'f' is Record<string, 'a' | 'b' | 'c'>
endAt('abc'),
limitToLast(2)
)
)
const snapshot3 = await get(
query(
// only Record<string, non-primitive>, PushAbleOnly<non-primitive> or NumericKeyRecord<non-primitive> can be ordered by child
exampleRef('g'),
// the type of orderByChild argument is relative paths of 'g' grandchildren node
// type of 'g' node is PushAbleOnly<{ h: number; j: { k: boolean } }>
// descendants node(starting at grandchildren) are 'h', 'j' and 'j/k'
// however 'j' is not a valid type because the type of node 'j' is non-primitive and will trigger type error
orderByChild('j/k'),
// the 1st argument is boolean because the type of node 'j/k' is boolean
// in this case it is non numeric string because 'f' is Record<string, 'a' | 'b' | 'c'>
// and the 2nd argument type is non-numeric string because the key of PushAbleOnly is string
equalTo(false, 'a1b2c3'),
limitToLast(2)
)
)
}

Listeners

import { exampleRef } from './createRef'
import {
orderByKey,
orderByValue,
startAt,
endAt,
limitToFirst,
limitToLast,
query,
onChildAdded,
onChildChanged,
onChildRemoved,
onChildMoved,
onValue,
} from 'firesagejs'

// onChild listener only listen to node of type Record<string, T>, PushAbleOnly<T> or NumericKeyRecord<T> can be listened
// onValue listener can listen to any node
// all listeners can listen to both ref and query
// (function overloading)every listener can accept onError callback(optional) or options(optional) or both
// snapshot typing is same as 'get' snapshot
// call unsub to remove the listener

const unsub = onChildAdded(
query(exampleRef('i'), orderByValue(), startAt('abc', '1'), limitToFirst(4)),
snapshot => {}
)

const unsub2 = onChildChanged(
exampleRef('g'),
snapshot => {},
error => {} // optional, onError callback
)

const unsub3 = onChildRemoved(
query(exampleRef('f'), orderByKey(), endAt('abc'), limitToLast(2)),
snapshot => {},
{ onlyOnce: false } // optional, options
)

const unsub4 = onChildMoved(
exampleRef('f'),
snapshot => {},
error => {}, // optional, onError callback
{ onlyOnce: false } // optional, options
)

const unsub6 = onValue(
exampleRef('b/d/e'),
snapshot => {},
error => {}, // optional, onError callback
{ onlyOnce: false } // optional, options
)

Transaction

import { exampleRef } from './createRef'
import { runTransaction } from 'firesagejs'

// type of 'g' node is PushAbleOnly<{ h: number; j: { k: boolean } }>
// hence the return type is { { h: number; j: { k: boolean } }
;async () => {
const result = await runTransaction(
exampleRef('g/a1b2c3'),
// type of 'data' is { h: number, j: { k: boolean } } | null
data => {
return { h: 123, j: { k: false } } // return to result
},
{ applyLocally: true } // optional, options
)

const committed = result.committed // boolean
const snapshot = result.snapshot // typing is same as 'get' snapshot
const json = result.toJSON() // object, we have to type cast it
}

OnDisconnect

import { exampleRef } from './createRef'
import { serverTimestamp, onDisconnect } from 'firesagejs'
//
;async () => {
const onDc = onDisconnect(exampleRef('b'))

await onDc.set({ c: false, d: { e: serverTimestamp() } })

await onDc.update(['c', 'd'], [false, { e: serverTimestamp() }])

await onDc.remove() // type error if 'b' is not removable, in this case 'b' is removable

await onDc.cancel()
}