From 54630a038ba00bba9a4b29bee9d29a8aced830a6 Mon Sep 17 00:00:00 2001 From: Piotr Kaminski Date: Tue, 10 Mar 2026 17:06:37 -0700 Subject: [PATCH] Optionally use strong type hierarchies for read outputs and write inputs. --- README.md | 21 +++++ package.json | 2 +- src/nodefire.ts | 229 +++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 212 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ec92581..ccacb71 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,27 @@ co( ); ``` +## TypeScript write placeholders + +`NodeFire` supports separate read and write type shapes. The optional second generic parameter +defines special write-value patterns, and defaults to none. + +Each pattern is `[keyPattern, baseType, specialValue]`, where `specialValue` is allowed for keys +matching `keyPattern` when the field includes `baseType`. +All nested write properties also accept `null` to support Firebase delete semantics. + +```ts +import NodeFire from 'nodefire'; + +type CounterIncrement = {'.sv': {increment: number}}; +type WritePatterns = [ + [`${string}timestamp`, number, {'.sv': string}], + [`${string}Count`, number, CounterIncrement] +]; + +const db = new NodeFire(admin.database().ref()); +``` + ## API This is reproduced from the source code, which is authoritative. diff --git a/package.json b/package.json index 7c532f9..760f749 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodefire", - "version": "4.0.0", + "version": "4.1.0", "description": "A promise-centric Firebase library for NodeJS", "main": "built/index.js", "types": "built/index.d.ts", diff --git a/src/nodefire.ts b/src/nodefire.ts index feaea5f..92b4c92 100644 --- a/src/nodefire.ts +++ b/src/nodefire.ts @@ -12,8 +12,15 @@ export type InterceptOperationsCallback = ( ) => Promise | void; export type PrimitiveValue = string | number | boolean | null; -export type Value = NonNullable | null; -export type StoredValue = any; +export interface Info { + connected: boolean; + serverTimeOffset: number; +} +export type NormalizedValue = + T extends readonly (infer U)[] ? Partial>>> : + T extends object ? {[K in keyof T]: NormalizedValue} : + T; +export type ReadValue = Exclude, undefined>; let cache: LRUCache | null; let cacheHits = 0, cacheMisses = 0; @@ -50,7 +57,11 @@ declare module '@firebase/database-types' { * standard option is `timeout`, which will cause an operation to time out after the given number of * milliseconds. Other operation-specific options are described in their respective doc comments. */ -export default class NodeFire { +export default class NodeFire< + Root = any, + WriteSpecialRules extends readonly WriteSpecialRule[] = [], + WriteRoot = WriteShape +> { /** * Flag that indicates whether to log transactions and the number of tries needed. */ @@ -123,7 +134,7 @@ export default class NodeFire { * Returns a NodeFire reference at the same location as this query or reference. * @return A NodeFire reference at the same location as this query or reference. */ - get ref(): NodeFire { + get ref(): NodeFire { if (this.$ref.isEqual(this.$ref.ref)) return this; return new NodeFire(this.$ref.ref, this.$scope); } @@ -132,7 +143,7 @@ export default class NodeFire { * Returns a NodeFire reference to the root of the database. * @return {NodeFire} The root reference of the database. */ - get root(): NodeFire { + get root(): NodeFire { if (this.$ref.isEqual(this.$ref.ref.root)) return this; return new NodeFire(this.$ref.ref.root, this.$scope); } @@ -142,7 +153,7 @@ export default class NodeFire { * reference is `null`. * @return {NodeFire|null} The parent location of this reference. */ - get parent(): NodeFire | null { + get parent(): NodeFire | null { if (this.$ref.ref.parent === null) return null; return new NodeFire(this.$ref.ref.parent, this.$scope); } @@ -223,7 +234,7 @@ export default class NodeFire { * precedence over) the one carried by this NodeFire object. * @return A new NodeFire object with the same reference and new scope. */ - scope(scope: Scope): NodeFire { + scope(scope: Scope): NodeFire { return new NodeFire(this.$ref, _.assign(_.clone(this.$scope), scope)); } @@ -236,6 +247,12 @@ export default class NodeFire { * scope. * @return {NodeFire} A new NodeFire object on the child reference, and with the augmented scope. */ + child

(path: P & ReadPathInput, scope?: Scope): + NodeFire, WriteSpecialRules, ResultFor>; + + child(path: string, scope?: Scope): + NodeFire, WriteSpecialRules, ResultFor>; + child(path: string, scope: Scope = {}): NodeFire { const child = this.scope(scope); return new NodeFire(this.$ref.ref.child(child.interpolate(path)), child.$scope); @@ -249,8 +266,12 @@ export default class NodeFire { * interpolated and must already be escaped, if necessary. * @returns {NodeFire} A new NodeFire object on the child reference. */ + childRaw

( + path: P & ReadPathInput + ): NodeFire, WriteSpecialRules, ResultFor>; + childRaw(path: string): NodeFire { - return new NodeFire(this.$ref.ref.child(path), _.clone(this.$scope)); + return new NodeFire(this.$ref.ref.child(path as string), _.clone(this.$scope)); } /** @@ -260,12 +281,12 @@ export default class NodeFire { * @return A promise that is resolved to the reference's value, or rejected with an * error. The value returned is normalized, meaning arrays are converted to objects. */ - get(options?: {timeout?: number, cache?: boolean}): Promise { + get(options?: {timeout?: number, cache?: boolean}): Promise | null> { return invoke( {ref: this, method: 'get', args: []}, options, (opts: {cache?: boolean}) => { if (opts.cache === undefined || opts.cache) this.cache(); - return this.$ref.once('value').then(snap => getNormalValue(snap)); + return this.$ref.once('value').then(snap => getNormalValue(snap)); } ); } @@ -308,7 +329,12 @@ export default class NodeFire { * @returns {Promise} A promise that is resolved when the value has been set, * or rejected with an error. */ - set(value: Value, options?: {timeout?: number}): Promise { + set(value: WriteRoot | null, options?: {timeout?: number}): Promise; + set(value: unknown, options: {timeout?: number, unchecked: true}): Promise; + set( + value: unknown, + options: {timeout?: number, unchecked?: boolean} = {} + ): Promise { return invoke( {ref: this, method: 'set', args: [value]}, options, (opts: any) => this.$ref.ref.set(value) @@ -323,7 +349,7 @@ export default class NodeFire { * @return {Promise} A promise that is resolved when the value has been updated, * or rejected with an error. */ - update(value: {[key: string]: Value}, options?: {timeout?: number}): Promise { + update(value: UpdateShape, options?: {timeout?: number}): Promise { if (_.isPlainObject(value) && _.isEmpty(value)) return Promise.resolve(); return invoke( {ref: this, method: 'update', args: [value]}, options, @@ -350,7 +376,7 @@ export default class NodeFire { * @return A promise that is resolved to a new NodeFire object that refers to the newly * pushed value (with the same scope as this object), or rejected with an error. */ - push(value: Value, options?: { timeout?: number }): Promise { + push(value: PushValue | null, options?: { timeout?: number }): Promise { if (_.isNil(value)) { return Promise.resolve(new NodeFire(this.$ref.ref.push(), this.$scope)); } @@ -384,14 +410,14 @@ export default class NodeFire { * @return {Promise} A promise that is resolved with the (normalized) committed value if the * transaction committed or with undefined if it aborted, or rejected with an error. */ - transaction( - updateFunction: (value: StoredValue) => T, + transaction( + updateFunction: (value: ReadValue | null) => T, options?: { detectStuck?: number, prefetchValue?: boolean, timeout?: number } - ): Promise { + ): Promise | null | undefined> { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; // easier than using => functions or binding explicitly options = options ?? {}; @@ -425,14 +451,14 @@ export default class NodeFire { (interceptor: InterceptOperationsCallback) => Promise.resolve(interceptor(op, options)) ) ).then(() => { - const promise = new Promise((resolve, reject) => { + const promise = new Promise | null | undefined>((resolve, reject) => { const wrappedRejectNoResult = wrapReject(self, 'transaction', reject); let wrappedReject = wrappedRejectNoResult; let aborted = false, settled = false; const inputValues: any[] = []; let numConsecutiveEqualInputValues = 0; - function wrappedUpdateFunction(value) { + function wrappedUpdateFunction(value: Root | null) { try { wrappedReject = wrappedRejectNoResult; if (aborted) return; // transaction otherwise aborted and promise settled, just stop @@ -489,7 +515,7 @@ export default class NodeFire { if (error) { wrappedReject(error); } else if (committed) { - resolve(getNormalValue(snap!)); + resolve(getNormalValue(snap!)); } else { resolve(undefined); } @@ -534,9 +560,9 @@ export default class NodeFire { */ on( eventType: admin.database.EventType, - callback: (a: admin.database.DataSnapshot, b?: string) => any, + callback: (a: Snapshot, b?: string) => any, cancelCallback?: ((a: Error) => any), context?: object - ): (a: admin.database.DataSnapshot, b?: string) => any { + ): (a: Snapshot, b?: string) => any { cancelCallback = wrapReject(this, 'on', cancelCallback); this.$ref.on( eventType, captureCallback(this, eventType, callback), cancelCallback, context); @@ -548,7 +574,7 @@ export default class NodeFire { */ off( eventType?: admin.database.EventType, - callback?: (a: admin.database.DataSnapshot, b?: string) => any, + callback?: (a: Snapshot, b?: string) => any, context?: object ): void { this.$ref.off(eventType, callback && popCallback(this, eventType, callback), context); @@ -739,7 +765,7 @@ export default class NodeFire { ref returns a NodeFire instance, val() normalizes the value, and child() takes an optional refining scope. */ -export class Snapshot { +export class Snapshot { $snap: admin.database.DataSnapshot; $nodeFire: NodeFire; constructor(snap: admin.database.DataSnapshot, nodeFire: NodeFire) { @@ -755,16 +781,18 @@ export class Snapshot { return new NodeFire(this.$snap.ref, this.$nodeFire.$scope); } - val(): Value { - return getNormalValue(this.$snap); + val(): ReadValue | null { + return getNormalValue(this.$snap); } - child(path: string, scope: Scope): Snapshot { + child

( + path: P & ReadPathInput, scope: Scope + ): Snapshot> { const childNodeFire = this.$nodeFire.scope(scope); - return new Snapshot(this.$snap.child(childNodeFire.interpolate(path)), childNodeFire); + return new Snapshot(this.$snap.child(childNodeFire.interpolate(path as string)), childNodeFire); } - forEach(callback: (snapshot: Snapshot) => any): any { + forEach(callback: (snapshot: Snapshot) => any): any { // eslint-disable-next-line lodash/prefer-lodash-method this.$snap.forEach(child => { return callback(new Snapshot(child, this.$nodeFire)); @@ -788,18 +816,18 @@ interface NodeFireCallbacks { } // We get a little tricky (using & to merge types) to allow attaching a property to a function. -type CapturableCallback = - ((a: admin.database.DataSnapshot, b?: string) => any) & +type CapturableCallback = + ((a: Snapshot, b?: string) => any) & {$nodeFireCallbacks?: NodeFireCallbacks}; // We need to wrap the user's callback so that we can wrap each snapshot, but must keep track of the // wrapper function in case the user calls off(). We don't reuse wrappers so that the number of // wrappers is equal to the number of on()s for that callback, and we can safely pop one with each // call to off(). -function captureCallback( +function captureCallback( nodeFire: NodeFire, eventType: admin.database.EventType, - callback: CapturableCallback, + callback: CapturableCallback, ): (a: admin.database.DataSnapshot, b?: string) => any { const key = eventType + '::' + nodeFire.toString(); callback.$nodeFireCallbacks = callback.$nodeFireCallbacks || {}; @@ -812,8 +840,9 @@ function captureCallback( return nodeFireCallback; } -function popCallback( - nodeFire: NodeFire, eventType: admin.database.EventType | undefined, callback: CapturableCallback +function popCallback( + nodeFire: NodeFire, eventType: admin.database.EventType | undefined, + callback: CapturableCallback ): NodeFireCallback { const key = eventType + '::' + nodeFire.toString(); return callback.$nodeFireCallbacks![key].pop(); @@ -881,11 +910,11 @@ function trimCache(ref) { }); } -function getNormalValue(snap: admin.database.DataSnapshot): Value { - return getNormalRawValue(snap.val()); +function getNormalValue(snap: admin.database.DataSnapshot): ReadValue | null { + return getNormalRawValue(snap.val()); } -function getNormalRawValue(value: any): Value { +function getNormalRawValue(value: T): ReadValue { if (_.isArray(value)) { const normalValue = {}; _.forEach(value, (item, key) => { @@ -893,13 +922,13 @@ function getNormalRawValue(value: any): Value { normalValue[key] = getNormalRawValue(item); } }); - value = normalValue; + value = normalValue as T; } else if (_.isObject(value)) { _.forEach(value, (item, key) => { value[key] = getNormalRawValue(item); }); } - return value; + return value as ReadValue; } function invoke(op, options: {timeout?: number} = {}, fn) { @@ -961,3 +990,125 @@ function handleError(error, op, callback) { return callback(error); }); } + + +/** Split "a/b/c" -> ["a","b","c"] */ +type Split = + S extends `${infer A}/${infer B}` ? (A extends '' ? Split : [A, ...Split]) : + S extends '' ? [] : + [S]; + +/** Normalize a single segment: + * ":foo" -> "$" + * "{foo.bar}" -> "$" + */ +type NormSeg = + S extends `:${string}` ? '$' : + S extends `{${string}}` ? '$' : + S; + +/** Map normalization across segments */ +type NormalizeParts

= + P extends [infer H extends string, ...infer R extends string[]] ? + [NormSeg, ...NormalizeParts] : []; + +/** Resolve a (normalized) path against a nested schema */ +type Resolve = + Parts extends [infer H extends string, ...infer R extends string[]] ? + (H extends keyof NonNullable ? Resolve[H], R> : never) : T; + + +/** Final: resolve Path from Root */ +type DataTypeFrom = Resolve>>; + +/** Branded error type */ +declare const PATH_ERROR_BRAND: unique symbol; +type PathError = { + readonly [PATH_ERROR_BRAND]: Message; +}; + +/** Show normalized parts in error messages (as "a/b/$/c") */ +type JoinParts

= + P extends [] ? '' : + P extends [infer H extends string] ? H : + P extends [infer H extends string, ...infer R extends string[]] ? `${H}/${JoinParts}` : + string; + +/** Turn a failed lookup into a branded error */ +type BrandedLookup = + P extends `/${string}` ? + PathError<`Path must not start with "/": "${P}"`> : + [DataTypeFrom] extends [never] ? + PathError<`Unknown path: "${P}" (normalized: "${JoinParts>>}")`> : + DataTypeFrom; + +/** Restrict literal path arguments while leaving widened strings alone */ +type PathInput = + string extends P ? P : + P extends any ? ( + P extends `/${string}` ? + PathError<`Path must not start with "/": "${P}"`> : + [DataTypeFrom] extends [never] ? + PathError<`Unknown path: "${P}" (normalized: "${JoinParts>>}")`> : + P + ) : + never; + +type ReadRootWithInfo = + Root extends object ? + Omit & {'.info'?: Info} : + Root; + +type ReadResultFor = ResultFor, P>; +type ReadPathInput = PathInput, P>; + +type ResultFor = + string extends P ? + // If P is a widened string (i.e., just `string`), allow and return unknown + unknown : + // If P is a literal (or union of literals), be strict and use branded error on failure + BrandedLookup; + +type WriteSpecialRule< + KeyPattern extends string = string, + BaseType = unknown, + SpecialValue = unknown +> = readonly [keyPattern: KeyPattern, baseType: BaseType, specialValue: SpecialValue]; + +type RuleSpecialForKey< + T, Key extends string, + Rules extends readonly WriteSpecialRule[] +> = + Rules extends readonly [ + infer Head extends WriteSpecialRule, + ...infer Tail extends readonly WriteSpecialRule[] + ] ? + Head extends WriteSpecialRule ? + ( + Key extends Pattern ? + ([Extract] extends [never] ? never : SpecialValue) : + never + ) | RuleSpecialForKey : + RuleSpecialForKey : + never; + +type NoUndefined = Exclude; + +type WriteShape< + T, + Rules extends readonly WriteSpecialRule[] = [] +> = + NoUndefined extends readonly (infer U)[] ? + ReadonlyArray, Rules> | null> : + T extends object ? { + [K in keyof T]: + K extends string ? + WriteShape | RuleSpecialForKey, K, Rules>, Rules> | null : + WriteShape, Rules> | null; + } : + NoUndefined; + +type PushValue = NonNullable extends {[key: string]: infer Child} ? Child : T; +type StrictPartial = {[K in keyof T]?: NoUndefined}; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type UpdateShape = StrictPartial & {[path: string]: {} | null};