shelving 1.274.0 → 1.276.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extract/ModuleExtractor.d.ts +1 -1
- package/extract/PackageExtractor.d.ts +2 -2
- package/extract/PackageExtractor.js +2 -2
- package/firebase/FirestoreProvider.d.ts +117 -0
- package/firebase/FirestoreProvider.js +313 -0
- package/firebase/index.d.ts +2 -0
- package/firebase/index.js +2 -0
- package/firebase/value.d.ts +80 -0
- package/firebase/value.js +109 -0
- package/index.js +1 -3
- package/package.json +5 -10
- package/test/basics.d.ts +20 -0
- package/test/basics.js +7 -0
- package/test/index.d.ts +1 -22
- package/test/index.js +1 -6
- package/test/people.d.ts +13 -0
- package/test/people.js +8 -1
- package/test/testDBProvider.d.ts +22 -0
- package/test/testDBProvider.js +272 -0
- package/util/data.d.ts +3 -1
- package/firestore/client/FirestoreClientProvider.d.ts +0 -38
- package/firestore/client/FirestoreClientProvider.js +0 -137
- package/firestore/client/index.d.ts +0 -1
- package/firestore/client/index.js +0 -1
- package/firestore/lite/FirestoreLiteProvider.d.ts +0 -40
- package/firestore/lite/FirestoreLiteProvider.js +0 -136
- package/firestore/lite/index.d.ts +0 -1
- package/firestore/lite/index.js +0 -1
- package/firestore/server/FirestoreServerProvider.d.ts +0 -42
- package/firestore/server/FirestoreServerProvider.js +0 -150
- package/firestore/server/index.d.ts +0 -1
- package/firestore/server/index.js +0 -1
|
@@ -6,7 +6,7 @@ import { Extractor } from "./Extractor.js";
|
|
|
6
6
|
* @see https://shelving.cc/extract/ModuleExtractorInput
|
|
7
7
|
*/
|
|
8
8
|
export interface ModuleExtractorInput {
|
|
9
|
-
/** Display name for the module, derived from the package.json export key (e.g. `"util/string"`, `"
|
|
9
|
+
/** Display name for the module, derived from the package.json export key (e.g. `"util/string"`, `"firebase"`). */
|
|
10
10
|
readonly name: string;
|
|
11
11
|
/**
|
|
12
12
|
* The source element this module is built from.
|
|
@@ -29,7 +29,7 @@ export interface PackageExtractorOptions {
|
|
|
29
29
|
/**
|
|
30
30
|
* Extractor that reads a `package.json` and produces a flat tree of modules — one `kind: "module"`
|
|
31
31
|
* `DocumentationElement` per export entry, in declaration order.
|
|
32
|
-
* - Static export keys (e.g. `"./api"`, `"./
|
|
32
|
+
* - Static export keys (e.g. `"./api"`, `"./firebase"`) become one module each.
|
|
33
33
|
* - Wildcard export keys (e.g. `"./util/*"`) expand against the source tree — one module per matching child file or subdirectory.
|
|
34
34
|
* - Each export's *target* extension (e.g. the `.js` in `"./util/*.js"`) is mapped to source extensions via `extensions`, so built `.js` paths resolve to their `.ts` sources.
|
|
35
35
|
* - Each module's `title` is prefixed with the package `name` (e.g. `ui` → `shelving/ui`) so listings read as package subpaths.
|
|
@@ -50,7 +50,7 @@ export declare class PackageExtractor extends Extractor<Path, TreeElement> {
|
|
|
50
50
|
extract(packageJson: Path): Promise<TreeElement>;
|
|
51
51
|
/** Source extensions to try for an export `target`, derived from the target's own extension via the `extensions` mapping. */
|
|
52
52
|
private _sourceExtensions;
|
|
53
|
-
/** Resolve a static export subpath (e.g. `"
|
|
53
|
+
/** Resolve a static export subpath (e.g. `"nested/client"`) to a file or directory element in the tree. */
|
|
54
54
|
private _resolve;
|
|
55
55
|
/** Expand a wildcard export subpath (e.g. `"util/*"`) into one module per matching child. */
|
|
56
56
|
private _expandWildcard;
|
|
@@ -13,7 +13,7 @@ const DEFAULT_EXTENSIONS = {
|
|
|
13
13
|
/**
|
|
14
14
|
* Extractor that reads a `package.json` and produces a flat tree of modules — one `kind: "module"`
|
|
15
15
|
* `DocumentationElement` per export entry, in declaration order.
|
|
16
|
-
* - Static export keys (e.g. `"./api"`, `"./
|
|
16
|
+
* - Static export keys (e.g. `"./api"`, `"./firebase"`) become one module each.
|
|
17
17
|
* - Wildcard export keys (e.g. `"./util/*"`) expand against the source tree — one module per matching child file or subdirectory.
|
|
18
18
|
* - Each export's *target* extension (e.g. the `.js` in `"./util/*.js"`) is mapped to source extensions via `extensions`, so built `.js` paths resolve to their `.ts` sources.
|
|
19
19
|
* - Each module's `title` is prefixed with the package `name` (e.g. `ui` → `shelving/ui`) so listings read as package subpaths.
|
|
@@ -86,7 +86,7 @@ export class PackageExtractor extends Extractor {
|
|
|
86
86
|
const ext = dot >= 0 ? target.slice(dot + 1) : "";
|
|
87
87
|
return this._extensions[ext] ?? (ext ? [ext] : []);
|
|
88
88
|
}
|
|
89
|
-
/** Resolve a static export subpath (e.g. `"
|
|
89
|
+
/** Resolve a static export subpath (e.g. `"nested/client"`) to a file or directory element in the tree. */
|
|
90
90
|
_resolve(subpath, sourceExtensions) {
|
|
91
91
|
const segments = subpath.split("/");
|
|
92
92
|
let current = this._tree;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Collection } from "../db/collection/Collection.js";
|
|
2
|
+
import { DBProvider } from "../db/provider/DBProvider.js";
|
|
3
|
+
import type { ImmutableArray } from "../util/array.js";
|
|
4
|
+
import type { Data } from "../util/data.js";
|
|
5
|
+
import type { AnyCaller } from "../util/function.js";
|
|
6
|
+
import type { Item, Items, ItemsSequence, OptionalItem, OptionalItemSequence } from "../util/item.js";
|
|
7
|
+
import { type Query } from "../util/query.js";
|
|
8
|
+
import type { Updates } from "../util/update.js";
|
|
9
|
+
import type { FirestoreFields, FirestoreValue } from "./value.js";
|
|
10
|
+
/** JSON representation of a Firestore REST API `Write` operation. */
|
|
11
|
+
type _FirestoreWrite = {
|
|
12
|
+
readonly update?: {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly fields: FirestoreFields;
|
|
15
|
+
};
|
|
16
|
+
readonly delete?: string;
|
|
17
|
+
readonly updateMask?: {
|
|
18
|
+
readonly fieldPaths: ImmutableArray<string>;
|
|
19
|
+
};
|
|
20
|
+
readonly updateTransforms?: ImmutableArray<_FirestoreTransform>;
|
|
21
|
+
readonly currentDocument?: {
|
|
22
|
+
readonly exists: boolean;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** JSON representation of a Firestore REST API `FieldTransform` operation. */
|
|
26
|
+
type _FirestoreTransform = {
|
|
27
|
+
readonly fieldPath: string;
|
|
28
|
+
readonly increment?: FirestoreValue;
|
|
29
|
+
readonly appendMissingElements?: {
|
|
30
|
+
readonly values: ImmutableArray<FirestoreValue>;
|
|
31
|
+
};
|
|
32
|
+
readonly removeAllFromArray?: {
|
|
33
|
+
readonly values: ImmutableArray<FirestoreValue>;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/** Options for `FirestoreProvider`. */
|
|
37
|
+
export interface FirestoreProviderOptions {
|
|
38
|
+
/** Google Cloud project id. */
|
|
39
|
+
readonly project: string;
|
|
40
|
+
/**
|
|
41
|
+
* Firestore database id.
|
|
42
|
+
* @default "(default)"
|
|
43
|
+
*/
|
|
44
|
+
readonly database?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Base URL of the Firestore API, e.g. `"http://127.0.0.1:8080"` for the Firestore emulator.
|
|
47
|
+
* @default "https://firestore.googleapis.com"
|
|
48
|
+
*/
|
|
49
|
+
readonly host?: string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Return an OAuth2 access token for each request, e.g. from `google-auth-library` or a hand-rolled service-account JWT exchange.
|
|
52
|
+
* - Omit to send no `Authorization` header (correct for the Firestore emulator).
|
|
53
|
+
*/
|
|
54
|
+
readonly token?: (() => string | PromiseLike<string>) | undefined;
|
|
55
|
+
/** Fetch implementation to use for requests (defaults to the global `fetch`). */
|
|
56
|
+
readonly fetch?: ((input: string, init: RequestInit) => Promise<Response>) | undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cloud Firestore database provider that talks to the Firestore REST API using `fetch`, implementing the `DBProvider` abstraction.
|
|
60
|
+
*
|
|
61
|
+
* - Zero dependencies, so it runs anywhere `fetch` runs: Node.js, Bun, Cloudflare Workers, Deno, and other edge runtimes.
|
|
62
|
+
* - Supports transactions via `transact()` — reads see a consistent snapshot, writes buffer and commit atomically, and contended commits retry.
|
|
63
|
+
* - Does not support realtime subscriptions: `getItemSequence()` and `getQuerySequence()` throw `UnsupportedError` (the `:listen` endpoint requires a streaming session the plain REST API cannot provide).
|
|
64
|
+
* - Data read from Firestore is unvalidated — wrap in `ValidationDBProvider` to guarantee types.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const provider = new FirestoreProvider({ project: "my-project", token: () => auth.getAccessToken() });
|
|
68
|
+
* const id = await provider.addItem(users, { name: "Dave" });
|
|
69
|
+
*
|
|
70
|
+
* @see https://shelving.cc/firebase/FirestoreProvider
|
|
71
|
+
*/
|
|
72
|
+
export declare class FirestoreProvider<I extends string = string, T extends Data = Data> extends DBProvider<I, T> {
|
|
73
|
+
/** Options this provider was created with (shared with transaction providers). */
|
|
74
|
+
protected readonly _options: FirestoreProviderOptions;
|
|
75
|
+
/** Path of the documents root, `projects/{project}/databases/{database}/documents`. */
|
|
76
|
+
protected readonly _root: string;
|
|
77
|
+
/** Transaction id included as the consistency selector in reads (set only on transaction providers). */
|
|
78
|
+
protected _transaction: string | undefined;
|
|
79
|
+
constructor(options: FirestoreProviderOptions);
|
|
80
|
+
/** Get the full Firestore document name for an item. */
|
|
81
|
+
protected _getDocumentName(collection: Collection<string, I, Data>, id: string): string;
|
|
82
|
+
/** POST a request to a Firestore REST method on the documents root, e.g. `commit`, and return the parsed JSON response. */
|
|
83
|
+
protected _request(method: string, body: Data): Promise<unknown>;
|
|
84
|
+
/** The consistency selector props included in read request bodies. */
|
|
85
|
+
protected get _consistency(): Data;
|
|
86
|
+
/**
|
|
87
|
+
* Send a set of `Write` operations.
|
|
88
|
+
* - Committed immediately in batches of up to 500 (overridden by transaction providers to buffer until commit).
|
|
89
|
+
*/
|
|
90
|
+
protected _write(writes: ImmutableArray<_FirestoreWrite>): Promise<void>;
|
|
91
|
+
/** Run a query returning only the matching document names. */
|
|
92
|
+
protected _getQueryNames<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>> | undefined, caller: AnyCaller): Promise<ImmutableArray<string>>;
|
|
93
|
+
getItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<OptionalItem<II, TT>>;
|
|
94
|
+
/** Not supported — the REST API has no realtime listeners, so this throws `UnsupportedError`. */
|
|
95
|
+
getItemSequence<II extends I, TT extends T>(_collection: Collection<string, II, TT>, _id: II): OptionalItemSequence<II, TT>;
|
|
96
|
+
/** Generates a random 20-character id for the new item (fails if the id already exists). */
|
|
97
|
+
addItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, data: TT): Promise<II>;
|
|
98
|
+
setItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, data: TT): Promise<void>;
|
|
99
|
+
/** Fails if the item does not exist. */
|
|
100
|
+
updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
|
|
101
|
+
deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<void>;
|
|
102
|
+
countQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<number>;
|
|
103
|
+
getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
|
|
104
|
+
/** Not supported — the REST API has no realtime listeners, so this throws `UnsupportedError`. */
|
|
105
|
+
getQuerySequence<II extends I, TT extends T>(_collection: Collection<string, II, TT>, _query?: Query<Item<II, TT>>): ItemsSequence<II, TT>;
|
|
106
|
+
setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
|
|
107
|
+
updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
|
|
108
|
+
deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Runs the callback in a Firestore transaction: begin → reads with the transaction id → buffered writes committed atomically.
|
|
111
|
+
* - Retries the whole callback (up to 5 attempts) when the commit is aborted by contention, so the callback must have no side effects other than through its provider.
|
|
112
|
+
* - Rolls back and rethrows if the callback throws.
|
|
113
|
+
* - Reads see a consistent snapshot and never the transaction's own buffered writes.
|
|
114
|
+
*/
|
|
115
|
+
transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
|
|
116
|
+
}
|
|
117
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { DBProvider } from "../db/provider/DBProvider.js";
|
|
2
|
+
import { ResponseError } from "../error/ResponseError.js";
|
|
3
|
+
import { UnsupportedError } from "../error/UnsupportedError.js";
|
|
4
|
+
import { joinDataPath } from "../util/data.js";
|
|
5
|
+
import { BLACKHOLE } from "../util/function.js";
|
|
6
|
+
import { getItem } from "../util/item.js";
|
|
7
|
+
import { isPlainObject } from "../util/object.js";
|
|
8
|
+
import { getQueryFilters, getQueryLimit, getQueryOrders } from "../util/query.js";
|
|
9
|
+
import { getRandomKey } from "../util/random.js";
|
|
10
|
+
import { getUpdates } from "../util/update.js";
|
|
11
|
+
import { toData, toFirestoreFields, toFirestoreValue } from "./value.js";
|
|
12
|
+
// Constants.
|
|
13
|
+
const WRITE_BATCH = 500;
|
|
14
|
+
const TRANSACTION_ATTEMPTS = 5;
|
|
15
|
+
// Map `Filter.types` to Firestore REST `FieldFilter` operators.
|
|
16
|
+
const OPERATORS = {
|
|
17
|
+
is: "EQUAL",
|
|
18
|
+
not: "NOT_EQUAL",
|
|
19
|
+
in: "IN",
|
|
20
|
+
out: "NOT_IN",
|
|
21
|
+
contains: "ARRAY_CONTAINS",
|
|
22
|
+
gt: "GREATER_THAN",
|
|
23
|
+
gte: "GREATER_THAN_OR_EQUAL",
|
|
24
|
+
lt: "LESS_THAN",
|
|
25
|
+
lte: "LESS_THAN_OR_EQUAL",
|
|
26
|
+
};
|
|
27
|
+
/** Get the last segment of a Firestore document name, i.e. its id. */
|
|
28
|
+
function _getDocumentID(name) {
|
|
29
|
+
return name.slice(name.lastIndexOf("/") + 1); // `as I` needed: Firestore names are always plain strings.
|
|
30
|
+
}
|
|
31
|
+
/** Convert a Firestore REST document to an `Item`. */
|
|
32
|
+
function _getItem(document, caller) {
|
|
33
|
+
return getItem(_getDocumentID(document.name), toData(document.fields, caller)); // `as TT` needed: validate with `ValidationDBProvider` for real type safety.
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Convert a query to a Firestore REST `StructuredQuery`, or `undefined` if the query provably matches nothing (e.g. an empty `in` filter).
|
|
37
|
+
* - `id` keys map to the `__name__` field path with document-reference values.
|
|
38
|
+
*/
|
|
39
|
+
function _getStructuredQuery(root, collection, query, select, caller) {
|
|
40
|
+
const structured = { from: [{ collectionId: collection }] };
|
|
41
|
+
const filters = [];
|
|
42
|
+
if (query) {
|
|
43
|
+
for (const { key, operator, value } of getQueryFilters(query)) {
|
|
44
|
+
const k = joinDataPath(key);
|
|
45
|
+
const fieldPath = k === "id" ? "__name__" : k;
|
|
46
|
+
const encode = (v) => k === "id" ? { referenceValue: `${root}/${collection}/${String(v)}` } : toFirestoreValue(v, caller);
|
|
47
|
+
if (value === null && operator === "is")
|
|
48
|
+
filters.push({ unaryFilter: { op: "IS_NULL", field: { fieldPath } } });
|
|
49
|
+
else if (value === null && operator === "not")
|
|
50
|
+
filters.push({ unaryFilter: { op: "IS_NOT_NULL", field: { fieldPath } } });
|
|
51
|
+
else if (operator === "in" || operator === "out") {
|
|
52
|
+
const values = value;
|
|
53
|
+
if (!values.length) {
|
|
54
|
+
if (operator === "in")
|
|
55
|
+
return undefined; // `in []` matches nothing.
|
|
56
|
+
continue; // `out []` matches everything, so skip the filter.
|
|
57
|
+
}
|
|
58
|
+
filters.push({
|
|
59
|
+
fieldFilter: { field: { fieldPath }, op: OPERATORS[operator], value: { arrayValue: { values: values.map(encode) } } },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
filters.push({ fieldFilter: { field: { fieldPath }, op: OPERATORS[operator], value: encode(value) } });
|
|
64
|
+
}
|
|
65
|
+
const orders = getQueryOrders(query).map(({ key, direction }) => {
|
|
66
|
+
const k = joinDataPath(key);
|
|
67
|
+
return { field: { fieldPath: k === "id" ? "__name__" : k }, direction: direction === "asc" ? "ASCENDING" : "DESCENDING" };
|
|
68
|
+
});
|
|
69
|
+
if (orders.length)
|
|
70
|
+
structured.orderBy = orders;
|
|
71
|
+
const limit = getQueryLimit(query);
|
|
72
|
+
if (typeof limit === "number")
|
|
73
|
+
structured.limit = limit;
|
|
74
|
+
}
|
|
75
|
+
const [firstFilter] = filters;
|
|
76
|
+
if (filters.length > 1)
|
|
77
|
+
structured.where = { compositeFilter: { op: "AND", filters } };
|
|
78
|
+
else if (firstFilter)
|
|
79
|
+
structured.where = firstFilter;
|
|
80
|
+
if (select)
|
|
81
|
+
structured.select = { fields: [{ fieldPath: "__name__" }] };
|
|
82
|
+
return structured;
|
|
83
|
+
}
|
|
84
|
+
/** Convert an `Updates` object to a Firestore REST `Write` against a document name. */
|
|
85
|
+
function _getUpdateWrite(name, updates, caller) {
|
|
86
|
+
const fields = {};
|
|
87
|
+
const fieldPaths = [];
|
|
88
|
+
const transforms = [];
|
|
89
|
+
for (const { key, action, value } of getUpdates(updates)) {
|
|
90
|
+
const fieldPath = joinDataPath(key);
|
|
91
|
+
if (action === "set") {
|
|
92
|
+
_setDeepField(fields, key, toFirestoreValue(value, caller));
|
|
93
|
+
fieldPaths.push(fieldPath);
|
|
94
|
+
}
|
|
95
|
+
else if (action === "sum")
|
|
96
|
+
transforms.push({ fieldPath, increment: toFirestoreValue(value, caller) });
|
|
97
|
+
else if (action === "with")
|
|
98
|
+
transforms.push({ fieldPath, appendMissingElements: { values: value.map(v => toFirestoreValue(v, caller)) } });
|
|
99
|
+
else if (action === "omit")
|
|
100
|
+
transforms.push({ fieldPath, removeAllFromArray: { values: value.map(v => toFirestoreValue(v, caller)) } });
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
update: { name, fields },
|
|
104
|
+
updateMask: { fieldPaths },
|
|
105
|
+
...(transforms.length ? { updateTransforms: transforms } : {}),
|
|
106
|
+
currentDocument: { exists: true },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Set a value into nested Firestore fields at a deep key, creating intermediate maps. */
|
|
110
|
+
function _setDeepField(fields, segments, value) {
|
|
111
|
+
const [first, ...rest] = segments;
|
|
112
|
+
if (!rest.length) {
|
|
113
|
+
fields[first] = value;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const existing = fields[first]?.mapValue?.fields;
|
|
117
|
+
const nested = existing ? { ...existing } : {};
|
|
118
|
+
fields[first] = { mapValue: { fields: nested } };
|
|
119
|
+
_setDeepField(nested, rest, value);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Cloud Firestore database provider that talks to the Firestore REST API using `fetch`, implementing the `DBProvider` abstraction.
|
|
123
|
+
*
|
|
124
|
+
* - Zero dependencies, so it runs anywhere `fetch` runs: Node.js, Bun, Cloudflare Workers, Deno, and other edge runtimes.
|
|
125
|
+
* - Supports transactions via `transact()` — reads see a consistent snapshot, writes buffer and commit atomically, and contended commits retry.
|
|
126
|
+
* - Does not support realtime subscriptions: `getItemSequence()` and `getQuerySequence()` throw `UnsupportedError` (the `:listen` endpoint requires a streaming session the plain REST API cannot provide).
|
|
127
|
+
* - Data read from Firestore is unvalidated — wrap in `ValidationDBProvider` to guarantee types.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* const provider = new FirestoreProvider({ project: "my-project", token: () => auth.getAccessToken() });
|
|
131
|
+
* const id = await provider.addItem(users, { name: "Dave" });
|
|
132
|
+
*
|
|
133
|
+
* @see https://shelving.cc/firebase/FirestoreProvider
|
|
134
|
+
*/
|
|
135
|
+
export class FirestoreProvider extends DBProvider {
|
|
136
|
+
/** Options this provider was created with (shared with transaction providers). */
|
|
137
|
+
_options;
|
|
138
|
+
/** Path of the documents root, `projects/{project}/databases/{database}/documents`. */
|
|
139
|
+
_root;
|
|
140
|
+
/** Transaction id included as the consistency selector in reads (set only on transaction providers). */
|
|
141
|
+
_transaction = undefined;
|
|
142
|
+
constructor(options) {
|
|
143
|
+
super();
|
|
144
|
+
this._options = options;
|
|
145
|
+
const { project, database = "(default)" } = options;
|
|
146
|
+
this._root = `projects/${project}/databases/${database}/documents`;
|
|
147
|
+
}
|
|
148
|
+
/** Get the full Firestore document name for an item. */
|
|
149
|
+
_getDocumentName(collection, id) {
|
|
150
|
+
return `${this._root}/${collection.name}/${id}`;
|
|
151
|
+
}
|
|
152
|
+
/** POST a request to a Firestore REST method on the documents root, e.g. `commit`, and return the parsed JSON response. */
|
|
153
|
+
async _request(method, body) {
|
|
154
|
+
const { host = "https://firestore.googleapis.com", token, fetch: customFetch } = this._options;
|
|
155
|
+
const headers = { "Content-Type": "application/json" };
|
|
156
|
+
if (token)
|
|
157
|
+
headers.Authorization = `Bearer ${await token()}`;
|
|
158
|
+
const url = `${host}/v1/${this._root}:${method}`;
|
|
159
|
+
const init = { method: "POST", headers, body: JSON.stringify(body) };
|
|
160
|
+
const response = customFetch ? await customFetch(url, init) : await globalThis.fetch(url, init);
|
|
161
|
+
const json = await response.json().catch(() => undefined);
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const error = isPlainObject(json) && isPlainObject(json.error) ? json.error : undefined;
|
|
164
|
+
throw new ResponseError(typeof error?.message === "string" ? error.message : `Firestore ${method} request failed`, {
|
|
165
|
+
code: response.status,
|
|
166
|
+
status: error?.status,
|
|
167
|
+
provider: this,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return json;
|
|
171
|
+
}
|
|
172
|
+
/** The consistency selector props included in read request bodies. */
|
|
173
|
+
get _consistency() {
|
|
174
|
+
return this._transaction ? { transaction: this._transaction } : {};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Send a set of `Write` operations.
|
|
178
|
+
* - Committed immediately in batches of up to 500 (overridden by transaction providers to buffer until commit).
|
|
179
|
+
*/
|
|
180
|
+
async _write(writes) {
|
|
181
|
+
for (let i = 0; i < writes.length; i += WRITE_BATCH)
|
|
182
|
+
await this._request("commit", { writes: writes.slice(i, i + WRITE_BATCH) });
|
|
183
|
+
}
|
|
184
|
+
/** Run a query returning only the matching document names. */
|
|
185
|
+
async _getQueryNames(collection, query, caller) {
|
|
186
|
+
const structuredQuery = _getStructuredQuery(this._root, collection.name, query, true, caller);
|
|
187
|
+
if (!structuredQuery)
|
|
188
|
+
return [];
|
|
189
|
+
const results = (await this._request("runQuery", { structuredQuery, ...this._consistency }));
|
|
190
|
+
return results.flatMap(r => (r.document ? [r.document.name] : []));
|
|
191
|
+
}
|
|
192
|
+
async getItem(collection, id) {
|
|
193
|
+
const results = (await this._request("batchGet", {
|
|
194
|
+
documents: [this._getDocumentName(collection, id)],
|
|
195
|
+
...this._consistency,
|
|
196
|
+
}));
|
|
197
|
+
const found = results[0]?.found;
|
|
198
|
+
if (found)
|
|
199
|
+
return _getItem(found, this.getItem);
|
|
200
|
+
}
|
|
201
|
+
/** Not supported — the REST API has no realtime listeners, so this throws `UnsupportedError`. */
|
|
202
|
+
getItemSequence(_collection, _id) {
|
|
203
|
+
throw new UnsupportedError("FirestoreProvider does not support realtime subscriptions");
|
|
204
|
+
}
|
|
205
|
+
/** Generates a random 20-character id for the new item (fails if the id already exists). */
|
|
206
|
+
async addItem(collection, data) {
|
|
207
|
+
const id = getRandomKey(20); // `as II` needed: generated keys are always plain strings.
|
|
208
|
+
await this._write([
|
|
209
|
+
{
|
|
210
|
+
update: { name: this._getDocumentName(collection, id), fields: toFirestoreFields(data, this.addItem) },
|
|
211
|
+
currentDocument: { exists: false },
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
return id;
|
|
215
|
+
}
|
|
216
|
+
async setItem(collection, id, data) {
|
|
217
|
+
await this._write([{ update: { name: this._getDocumentName(collection, id), fields: toFirestoreFields(data, this.setItem) } }]);
|
|
218
|
+
}
|
|
219
|
+
/** Fails if the item does not exist. */
|
|
220
|
+
async updateItem(collection, id, updates) {
|
|
221
|
+
await this._write([_getUpdateWrite(this._getDocumentName(collection, id), updates, this.updateItem)]);
|
|
222
|
+
}
|
|
223
|
+
async deleteItem(collection, id) {
|
|
224
|
+
await this._write([{ delete: this._getDocumentName(collection, id) }]);
|
|
225
|
+
}
|
|
226
|
+
async countQuery(collection, query) {
|
|
227
|
+
const structuredQuery = _getStructuredQuery(this._root, collection.name, query, false, this.countQuery);
|
|
228
|
+
if (!structuredQuery)
|
|
229
|
+
return 0;
|
|
230
|
+
const results = (await this._request("runAggregationQuery", {
|
|
231
|
+
structuredAggregationQuery: { structuredQuery, aggregations: [{ alias: "count", count: {} }] },
|
|
232
|
+
...this._consistency,
|
|
233
|
+
}));
|
|
234
|
+
const count = results[0]?.result?.aggregateFields?.count;
|
|
235
|
+
return count ? Number(toData({ count }, this.countQuery).count) : 0;
|
|
236
|
+
}
|
|
237
|
+
async getQuery(collection, query) {
|
|
238
|
+
const structuredQuery = _getStructuredQuery(this._root, collection.name, query, false, this.getQuery);
|
|
239
|
+
if (!structuredQuery)
|
|
240
|
+
return [];
|
|
241
|
+
const results = (await this._request("runQuery", { structuredQuery, ...this._consistency }));
|
|
242
|
+
return results.flatMap(r => (r.document ? [_getItem(r.document, this.getQuery)] : []));
|
|
243
|
+
}
|
|
244
|
+
/** Not supported — the REST API has no realtime listeners, so this throws `UnsupportedError`. */
|
|
245
|
+
getQuerySequence(_collection, _query) {
|
|
246
|
+
throw new UnsupportedError("FirestoreProvider does not support realtime subscriptions");
|
|
247
|
+
}
|
|
248
|
+
async setQuery(collection, query, data) {
|
|
249
|
+
const names = await this._getQueryNames(collection, query, this.setQuery);
|
|
250
|
+
const fields = toFirestoreFields(data, this.setQuery);
|
|
251
|
+
await this._write(names.map(name => ({ update: { name, fields } })));
|
|
252
|
+
}
|
|
253
|
+
async updateQuery(collection, query, updates) {
|
|
254
|
+
const names = await this._getQueryNames(collection, query, this.updateQuery);
|
|
255
|
+
await this._write(names.map(name => _getUpdateWrite(name, updates, this.updateQuery)));
|
|
256
|
+
}
|
|
257
|
+
async deleteQuery(collection, query) {
|
|
258
|
+
const names = await this._getQueryNames(collection, query, this.deleteQuery);
|
|
259
|
+
await this._write(names.map(name => ({ delete: name })));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Runs the callback in a Firestore transaction: begin → reads with the transaction id → buffered writes committed atomically.
|
|
263
|
+
* - Retries the whole callback (up to 5 attempts) when the commit is aborted by contention, so the callback must have no side effects other than through its provider.
|
|
264
|
+
* - Rolls back and rethrows if the callback throws.
|
|
265
|
+
* - Reads see a consistent snapshot and never the transaction's own buffered writes.
|
|
266
|
+
*/
|
|
267
|
+
async transact(callback) {
|
|
268
|
+
let retryTransaction;
|
|
269
|
+
let aborted;
|
|
270
|
+
for (let attempt = 0; attempt < TRANSACTION_ATTEMPTS; attempt++) {
|
|
271
|
+
const { transaction } = (await this._request("beginTransaction", {
|
|
272
|
+
options: { readWrite: retryTransaction ? { retryTransaction } : {} },
|
|
273
|
+
}));
|
|
274
|
+
const provider = new _FirestoreTransaction(this._options, transaction);
|
|
275
|
+
let result;
|
|
276
|
+
try {
|
|
277
|
+
result = await callback(provider);
|
|
278
|
+
}
|
|
279
|
+
catch (thrown) {
|
|
280
|
+
await this._request("rollback", { transaction }).catch(BLACKHOLE);
|
|
281
|
+
throw thrown;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
await this._request("commit", { transaction, writes: provider.writes });
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
catch (thrown) {
|
|
288
|
+
if (!(thrown instanceof ResponseError) || thrown.status !== "ABORTED")
|
|
289
|
+
throw thrown;
|
|
290
|
+
retryTransaction = transaction; // Retry the transaction after contention.
|
|
291
|
+
aborted = thrown;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw aborted;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Transaction-scoped provider for `FirestoreProvider.transact()` — reads carry the transaction id, writes buffer until commit. */
|
|
298
|
+
class _FirestoreTransaction extends FirestoreProvider {
|
|
299
|
+
/** The buffered `Write` operations, sent in the transaction's final commit. */
|
|
300
|
+
writes = [];
|
|
301
|
+
constructor(options, transaction) {
|
|
302
|
+
super(options);
|
|
303
|
+
this._transaction = transaction;
|
|
304
|
+
}
|
|
305
|
+
/** Buffer the writes until the transaction commits. */
|
|
306
|
+
async _write(writes) {
|
|
307
|
+
this.writes.push(...writes);
|
|
308
|
+
}
|
|
309
|
+
/** Not supported inside a transaction — always throws `UnsupportedError`. */
|
|
310
|
+
transact(_callback) {
|
|
311
|
+
throw new UnsupportedError("FirestoreProvider does not support nested transactions");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ImmutableArray } from "../util/array.js";
|
|
2
|
+
import type { Data } from "../util/data.js";
|
|
3
|
+
import type { AnyCaller } from "../util/function.js";
|
|
4
|
+
/** JSON representation of a single value in the Firestore REST API. */
|
|
5
|
+
export type FirestoreValue = {
|
|
6
|
+
readonly nullValue?: null;
|
|
7
|
+
readonly booleanValue?: boolean;
|
|
8
|
+
readonly integerValue?: string | number;
|
|
9
|
+
readonly doubleValue?: string | number;
|
|
10
|
+
readonly stringValue?: string;
|
|
11
|
+
readonly timestampValue?: string;
|
|
12
|
+
readonly bytesValue?: string;
|
|
13
|
+
readonly referenceValue?: string;
|
|
14
|
+
readonly geoPointValue?: {
|
|
15
|
+
readonly latitude?: number;
|
|
16
|
+
readonly longitude?: number;
|
|
17
|
+
};
|
|
18
|
+
readonly arrayValue?: {
|
|
19
|
+
readonly values?: ImmutableArray<FirestoreValue>;
|
|
20
|
+
};
|
|
21
|
+
readonly mapValue?: {
|
|
22
|
+
readonly fields?: FirestoreFields;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** JSON representation of the fields of a Firestore document in the Firestore REST API. */
|
|
26
|
+
export type FirestoreFields = {
|
|
27
|
+
readonly [key: string]: FirestoreValue;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Convert a data value to its Firestore REST API JSON representation.
|
|
31
|
+
*
|
|
32
|
+
* - Safe integers become `integerValue` (which the REST API encodes as a string), other finite numbers become `doubleValue`.
|
|
33
|
+
* - Arrays and plain objects convert recursively.
|
|
34
|
+
*
|
|
35
|
+
* @param value The value to convert (null, boolean, string, finite number, array, or plain object).
|
|
36
|
+
* @param caller Caller function used to attribute thrown errors.
|
|
37
|
+
* @returns The Firestore JSON value.
|
|
38
|
+
* @throws `ValueError` if the value cannot be represented (e.g. `undefined`, functions, non-finite numbers, class instances).
|
|
39
|
+
* @example toFirestoreValue(123) // { integerValue: "123" }
|
|
40
|
+
* @see https://shelving.cc/firebase/toFirestoreValue
|
|
41
|
+
*/
|
|
42
|
+
export declare function toFirestoreValue(value: unknown, caller?: AnyCaller): FirestoreValue;
|
|
43
|
+
/**
|
|
44
|
+
* Convert a data object to Firestore REST API document fields.
|
|
45
|
+
*
|
|
46
|
+
* - Props with `undefined` value are skipped (matching JSON serialisation).
|
|
47
|
+
*
|
|
48
|
+
* @param data The data object to convert.
|
|
49
|
+
* @param caller Caller function used to attribute thrown errors.
|
|
50
|
+
* @returns The Firestore fields object.
|
|
51
|
+
* @throws `ValueError` if any prop value cannot be represented.
|
|
52
|
+
* @example toFirestoreFields({ num: 123 }) // { num: { integerValue: "123" } }
|
|
53
|
+
* @see https://shelving.cc/firebase/toFirestoreFields
|
|
54
|
+
*/
|
|
55
|
+
export declare function toFirestoreFields(data: Data, caller?: AnyCaller): FirestoreFields;
|
|
56
|
+
/**
|
|
57
|
+
* Convert a Firestore REST API JSON value back to a data value.
|
|
58
|
+
*
|
|
59
|
+
* - `integerValue` parses to a `number`, so integers beyond `Number.MAX_SAFE_INTEGER` lose precision.
|
|
60
|
+
* - `timestampValue`, `bytesValue`, and `referenceValue` pass through as their string form; `geoPointValue` becomes a plain `{ latitude, longitude }` object — wrap the provider in `ValidationDBProvider` to reject types your schemas don't allow.
|
|
61
|
+
*
|
|
62
|
+
* @param value The Firestore JSON value to convert.
|
|
63
|
+
* @param caller Caller function used to attribute thrown errors.
|
|
64
|
+
* @returns The plain data value.
|
|
65
|
+
* @throws `ValueError` if the value has no recognised type.
|
|
66
|
+
* @example toDataValue({ integerValue: "123" }) // 123
|
|
67
|
+
* @see https://shelving.cc/firebase/toDataValue
|
|
68
|
+
*/
|
|
69
|
+
export declare function toDataValue(value: FirestoreValue, caller?: AnyCaller): unknown;
|
|
70
|
+
/**
|
|
71
|
+
* Convert Firestore REST API document fields back to a data object.
|
|
72
|
+
*
|
|
73
|
+
* @param fields The Firestore fields object to convert (missing means an empty document).
|
|
74
|
+
* @param caller Caller function used to attribute thrown errors.
|
|
75
|
+
* @returns The plain data object.
|
|
76
|
+
* @throws `ValueError` if any field value has no recognised type.
|
|
77
|
+
* @example toData({ num: { integerValue: "123" } }) // { num: 123 }
|
|
78
|
+
* @see https://shelving.cc/firebase/toData
|
|
79
|
+
*/
|
|
80
|
+
export declare function toData(fields: FirestoreFields | undefined, caller?: AnyCaller): Data;
|