terrascale 0.3.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/LICENSE +202 -0
- package/README.md +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- package/types/ts-auth.d.ts +1826 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The current TerraBase document protocol uses canonical UUID entity IDs.
|
|
3
|
+
* @typedef {string} CanonicalUuid
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* All 64-bit quantities stay bigint in JavaScript and decimal strings on the wire.
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* readonly tenant: CanonicalUuid;
|
|
10
|
+
* readonly database: CanonicalUuid;
|
|
11
|
+
* readonly shard: bigint;
|
|
12
|
+
* }} DatabaseScope
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* readonly collection: CanonicalUuid;
|
|
18
|
+
* readonly schema_generation: bigint;
|
|
19
|
+
* readonly schema_digest: string;
|
|
20
|
+
* }} CollectionBinding
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {(
|
|
25
|
+
* | { readonly type: "null" }
|
|
26
|
+
* | { readonly type: "bool"; readonly value: boolean }
|
|
27
|
+
* | { readonly type: "i64"; readonly value: bigint }
|
|
28
|
+
* | { readonly type: "utf8"; readonly value: string }
|
|
29
|
+
* | { readonly type: "bytes"; readonly value: Uint8Array }
|
|
30
|
+
* | { readonly type: "map"; readonly fields: readonly DatabaseField[] }
|
|
31
|
+
* | { readonly type: "array"; readonly items: readonly DatabaseValue[] }
|
|
32
|
+
* )} DatabaseValue
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Missing fields are omitted; an explicit null remains a typed null value.
|
|
37
|
+
* @typedef {{ readonly id: number; readonly value: DatabaseValue }} DatabaseField
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{
|
|
42
|
+
* readonly replica: CanonicalUuid;
|
|
43
|
+
* readonly incarnation: string;
|
|
44
|
+
* readonly sequence: bigint;
|
|
45
|
+
* }} DatabaseObserved
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {DatabaseObserved & {
|
|
50
|
+
* readonly database: CanonicalUuid;
|
|
51
|
+
* readonly shard: bigint;
|
|
52
|
+
* }} DatabaseEvent
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {(
|
|
57
|
+
* | { readonly mode: "leaderless" }
|
|
58
|
+
* | {
|
|
59
|
+
* readonly mode: "single_writer";
|
|
60
|
+
* readonly generation: bigint;
|
|
61
|
+
* readonly writer: { readonly replica: CanonicalUuid; readonly incarnation: string };
|
|
62
|
+
* }
|
|
63
|
+
* )} DatabaseAuthority
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {(
|
|
68
|
+
* | { readonly kind: "none" }
|
|
69
|
+
* | { readonly kind: "absent" }
|
|
70
|
+
* | { readonly kind: "version"; readonly version: string }
|
|
71
|
+
* )} DatabaseCondition
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {(
|
|
76
|
+
* | { readonly kind: "live"; readonly fields: readonly DatabaseField[] }
|
|
77
|
+
* | { readonly kind: "tombstone" }
|
|
78
|
+
* )} DatabaseWriteValue
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {CollectionBinding & {
|
|
83
|
+
* readonly document: CanonicalUuid;
|
|
84
|
+
* readonly condition: DatabaseCondition;
|
|
85
|
+
* readonly observed: readonly DatabaseObserved[];
|
|
86
|
+
* readonly value: DatabaseWriteValue;
|
|
87
|
+
* }} DatabaseWrite
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/** @typedef {{ readonly expires_at_unix_ms: bigint; readonly nonce: string }} DatabaseTransactionId */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {{
|
|
94
|
+
* readonly transaction_id: DatabaseTransactionId;
|
|
95
|
+
* readonly authority: DatabaseAuthority;
|
|
96
|
+
* readonly writes: readonly DatabaseWrite[];
|
|
97
|
+
* }} DatabaseCommit
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @typedef {Omit<CollectionBinding, "collection"> & {
|
|
102
|
+
* readonly event: DatabaseEvent;
|
|
103
|
+
* } & DatabaseWriteValue} DatabaseSibling
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `state_version`: only a unique live or deleted state carries an atomic condition token.
|
|
108
|
+
* @typedef {{
|
|
109
|
+
* readonly version: 1;
|
|
110
|
+
* readonly state: "absent" | "deleted" | "value" | "conflict";
|
|
111
|
+
* readonly state_version: string | null;
|
|
112
|
+
* readonly context: readonly DatabaseObserved[];
|
|
113
|
+
* readonly siblings: readonly DatabaseSibling[];
|
|
114
|
+
* }} DatabasePoint
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/** @typedef {Extract<DatabaseValue, { readonly type: "bool" | "i64" | "utf8" | "bytes" }>} DatabaseQueryValue */
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @typedef {(
|
|
121
|
+
* | {
|
|
122
|
+
* readonly op: "equal" | "less_than" | "less_than_or_equal" | "greater_than" | "greater_than_or_equal";
|
|
123
|
+
* readonly path: readonly number[];
|
|
124
|
+
* readonly value: DatabaseQueryValue;
|
|
125
|
+
* }
|
|
126
|
+
* | { readonly op: "is_null" | "is_missing"; readonly path: readonly number[] }
|
|
127
|
+
* )} DatabaseQueryPredicate
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {{
|
|
132
|
+
* readonly predicate: DatabaseQueryPredicate;
|
|
133
|
+
* readonly limit: number;
|
|
134
|
+
* readonly cursor?: string;
|
|
135
|
+
* }} DatabaseQuery
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/** @typedef {{ readonly document: CanonicalUuid; readonly point: DatabasePoint }} DatabaseQueryRow */
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {{
|
|
142
|
+
* readonly version: 1;
|
|
143
|
+
* readonly rows: readonly DatabaseQueryRow[];
|
|
144
|
+
* readonly next_cursor?: string;
|
|
145
|
+
* }} DatabaseQueryPage
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @typedef {{
|
|
150
|
+
* readonly version: 1;
|
|
151
|
+
* readonly durability: "local_durable" | "object_durable";
|
|
152
|
+
* readonly transaction_id: DatabaseTransactionId;
|
|
153
|
+
* readonly event: DatabaseEvent;
|
|
154
|
+
* readonly commit_sequence: bigint;
|
|
155
|
+
* readonly effect_digest: string;
|
|
156
|
+
* readonly authority: DatabaseAuthority;
|
|
157
|
+
* }} DatabaseCommitReceipt
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @typedef {{
|
|
162
|
+
* readonly transaction_id: DatabaseTransactionId;
|
|
163
|
+
* readonly authority: DatabaseAuthority;
|
|
164
|
+
* readonly effect_digest: string;
|
|
165
|
+
* }} DatabaseTransactionIdentity
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @typedef {DatabaseTransactionIdentity & { readonly version: 1 } & (
|
|
170
|
+
* | { readonly state: "committed"; readonly outcome: { readonly receipt: DatabaseCommitReceipt } }
|
|
171
|
+
* | { readonly state: "rejected"; readonly outcome: { readonly error: { readonly code: "precondition_failed" } } }
|
|
172
|
+
* | { readonly state: "pending" | "unknown" | "expired"; readonly outcome: null }
|
|
173
|
+
* )} DatabaseTransactionStatus
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* An uncertain request needs status resolution; it is never an aborted transaction.
|
|
178
|
+
* @template T
|
|
179
|
+
* @typedef {(
|
|
180
|
+
* | { readonly ok: true; readonly status: number; readonly value: T }
|
|
181
|
+
* | {
|
|
182
|
+
* readonly ok: false;
|
|
183
|
+
* readonly error: {
|
|
184
|
+
* readonly code: string;
|
|
185
|
+
* readonly status?: number;
|
|
186
|
+
* readonly mutation_outcome?: "rejected" | "uncertain" | "unknown";
|
|
187
|
+
* readonly transaction?: DatabaseTransactionIdentity;
|
|
188
|
+
* };
|
|
189
|
+
* }
|
|
190
|
+
* )} DatabaseResult
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @typedef {(
|
|
195
|
+
* | {
|
|
196
|
+
* readonly phase: "snapshot";
|
|
197
|
+
* readonly predicate: DatabaseQueryPredicate;
|
|
198
|
+
* readonly limit: number;
|
|
199
|
+
* readonly snapshot_cursor?: string;
|
|
200
|
+
* }
|
|
201
|
+
* | { readonly phase: "poll"; readonly predicate: DatabaseQueryPredicate; readonly poll_cursor: string }
|
|
202
|
+
* )} DatabaseSubscriptionRequest
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/** @typedef {{ readonly document: CanonicalUuid; readonly point: DatabasePoint | null }} DatabaseSubscriptionEffect */
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @typedef {{
|
|
209
|
+
* readonly sequence: bigint;
|
|
210
|
+
* readonly effects: readonly DatabaseSubscriptionEffect[];
|
|
211
|
+
* }} DatabaseSubscriptionGroup
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @typedef {(
|
|
216
|
+
* | {
|
|
217
|
+
* readonly version: 1;
|
|
218
|
+
* readonly phase: "snapshot";
|
|
219
|
+
* readonly rows: readonly DatabaseQueryRow[];
|
|
220
|
+
* readonly next_snapshot_cursor: string;
|
|
221
|
+
* }
|
|
222
|
+
* | {
|
|
223
|
+
* readonly version: 1;
|
|
224
|
+
* readonly phase: "snapshot";
|
|
225
|
+
* readonly rows: readonly DatabaseQueryRow[];
|
|
226
|
+
* readonly poll_cursor: string;
|
|
227
|
+
* }
|
|
228
|
+
* | {
|
|
229
|
+
* readonly version: 1;
|
|
230
|
+
* readonly phase: "poll";
|
|
231
|
+
* readonly groups: readonly DatabaseSubscriptionGroup[];
|
|
232
|
+
* readonly poll_cursor: string;
|
|
233
|
+
* }
|
|
234
|
+
* )} DatabaseSubscriptionResponse
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
export {};
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { encodeDatabaseSubscription, requireDatabaseUuid, requireDatabaseU64 } from "./database-codec.js";
|
|
2
|
+
/**
|
|
3
|
+
* @import { TerraBaseClient } from "./database.js"
|
|
4
|
+
* @import {
|
|
5
|
+
* DatabaseField, DatabasePoint, DatabaseQueryPredicate, DatabaseQueryRow,
|
|
6
|
+
* DatabaseResult, DatabaseScope, DatabaseSubscriptionRequest,
|
|
7
|
+
* DatabaseSubscriptionResponse, DatabaseValue,
|
|
8
|
+
* } from "./database-types.js"
|
|
9
|
+
* @import { ReactiveReset, ReactiveSubscriptionContext } from "./react/core.js"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* - `limit`: snapshot page size, between 1 and 128.
|
|
14
|
+
* - `maximumRows`: client memory bound, including all snapshot pages and later inserts.
|
|
15
|
+
* - `retryLimit`: bounded retries of read transport failures with the identical cursor.
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* readonly collection: string;
|
|
18
|
+
* readonly predicate: DatabaseQueryPredicate;
|
|
19
|
+
* readonly limit?: number;
|
|
20
|
+
* readonly maximumRows?: number;
|
|
21
|
+
* readonly pollIntervalMs?: number;
|
|
22
|
+
* readonly retryLimit?: number;
|
|
23
|
+
* readonly retryDelayMs?: number;
|
|
24
|
+
* }} DatabaseQueryOptions
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* readonly scope: DatabaseScope;
|
|
30
|
+
* readonly collection: string;
|
|
31
|
+
* readonly predicate: DatabaseQueryPredicate;
|
|
32
|
+
* readonly limit: number;
|
|
33
|
+
* }} DatabaseViewBinding
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Local generation and server position of one complete authorized cut.
|
|
38
|
+
* @typedef {{
|
|
39
|
+
* readonly generation: number;
|
|
40
|
+
* readonly poll_cursor: string;
|
|
41
|
+
* readonly sequence?: bigint;
|
|
42
|
+
* }} DatabaseViewPosition
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {{
|
|
47
|
+
* readonly rows: readonly DatabaseQueryRow[];
|
|
48
|
+
* readonly position: DatabaseViewPosition;
|
|
49
|
+
* }} DatabaseViewSnapshot
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `next`: an async consumer can acknowledge publication before the next poll.
|
|
54
|
+
* @typedef {{
|
|
55
|
+
* readonly next: (rows: readonly DatabaseQueryRow[], position: DatabaseViewPosition) => void | Promise<void>;
|
|
56
|
+
* readonly error: (error: unknown) => void;
|
|
57
|
+
* readonly reset: (reset: ReactiveReset<readonly DatabaseQueryRow[]>) => void;
|
|
58
|
+
* }} DatabaseQueryCallbacks
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {{
|
|
63
|
+
* readonly key?: string | readonly unknown[];
|
|
64
|
+
* readonly binding: DatabaseViewBinding;
|
|
65
|
+
* readonly subscribe: (
|
|
66
|
+
* observer: DatabaseQueryCallbacks,
|
|
67
|
+
* context: ReactiveSubscriptionContext,
|
|
68
|
+
* ) => () => void;
|
|
69
|
+
* }} DatabaseQuerySource
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
export class DatabaseSubscriptionError extends Error {
|
|
73
|
+
/**
|
|
74
|
+
* @override
|
|
75
|
+
* @readonly
|
|
76
|
+
* @type {"DatabaseSubscriptionError"}
|
|
77
|
+
*/
|
|
78
|
+
name = "DatabaseSubscriptionError";
|
|
79
|
+
/**
|
|
80
|
+
* @readonly
|
|
81
|
+
* @type {string}
|
|
82
|
+
*/
|
|
83
|
+
code;
|
|
84
|
+
/**
|
|
85
|
+
* @readonly
|
|
86
|
+
* @type {Extract<DatabaseResult<never>, { readonly ok: false }>["error"] | undefined}
|
|
87
|
+
*/
|
|
88
|
+
detail;
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} code
|
|
91
|
+
* @param {Extract<DatabaseResult<never>, { readonly ok: false }>["error"]} [detail]
|
|
92
|
+
*/
|
|
93
|
+
constructor(code, detail) {
|
|
94
|
+
super(`TerraBase subscription: ${code}`, { cause: detail });
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.detail = detail;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {number | undefined} value
|
|
102
|
+
* @param {number} fallback
|
|
103
|
+
* @param {number} maximum
|
|
104
|
+
* @param {string} label
|
|
105
|
+
* @returns {number}
|
|
106
|
+
*/
|
|
107
|
+
function boundedInteger(value, fallback, maximum, label) {
|
|
108
|
+
const resolved = value ?? fallback;
|
|
109
|
+
if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) throw new TypeError(`${label} is outside its bounds.`);
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {DatabaseValue} value
|
|
115
|
+
* @returns {DatabaseValue}
|
|
116
|
+
*/
|
|
117
|
+
function valueCopy(value) {
|
|
118
|
+
switch (value.type) {
|
|
119
|
+
case "bytes": {
|
|
120
|
+
const bytes = value.value.slice();
|
|
121
|
+
// A typed array cannot be frozen. Keep its backing bytes private and
|
|
122
|
+
// return a copy on every read, including reads by another subscriber.
|
|
123
|
+
return Object.freeze({ type: /** @type {const} */ ("bytes"), get value() { return bytes.slice(); } });
|
|
124
|
+
}
|
|
125
|
+
case "map": return Object.freeze({ type: "map", fields: fieldsCopy(value.fields) });
|
|
126
|
+
case "array": return Object.freeze({ type: "array", items: Object.freeze(value.items.map(valueCopy)) });
|
|
127
|
+
default: return Object.freeze({ ...value });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {readonly DatabaseField[]} fields
|
|
133
|
+
* @returns {readonly DatabaseField[]}
|
|
134
|
+
*/
|
|
135
|
+
function fieldsCopy(fields) {
|
|
136
|
+
return Object.freeze(fields.map(field => Object.freeze({ id: field.id, value: valueCopy(field.value) })));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {DatabaseQueryPredicate} predicate
|
|
141
|
+
* @returns {DatabaseQueryPredicate}
|
|
142
|
+
*/
|
|
143
|
+
function predicateCopy(predicate) {
|
|
144
|
+
const path = Object.freeze([...predicate.path]);
|
|
145
|
+
if (!("value" in predicate)) return Object.freeze({ op: predicate.op, path });
|
|
146
|
+
return Object.freeze({ op: predicate.op, path, value: /** @type {typeof predicate.value} */ (valueCopy(predicate.value)) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {DatabasePoint} point
|
|
151
|
+
* @param {DatabaseScope} scope
|
|
152
|
+
* @returns {DatabasePoint}
|
|
153
|
+
*/
|
|
154
|
+
function pointCopy(point, scope) {
|
|
155
|
+
if (point.siblings.some(sibling => sibling.event.database !== scope.database || sibling.event.shard !== scope.shard)) {
|
|
156
|
+
throw new DatabaseSubscriptionError("scope_mismatch");
|
|
157
|
+
}
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
...point,
|
|
160
|
+
context: Object.freeze(point.context.map(observed => Object.freeze({ ...observed }))),
|
|
161
|
+
siblings: Object.freeze(point.siblings.map(sibling => Object.freeze({
|
|
162
|
+
...sibling,
|
|
163
|
+
event: Object.freeze({ ...sibling.event }),
|
|
164
|
+
...(sibling.kind === "live" ? { fields: fieldsCopy(sibling.fields) } : {}),
|
|
165
|
+
}))),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {string} value
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function cursor(value) {
|
|
174
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) throw new DatabaseSubscriptionError("invalid_cursor");
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Pure native result reducer. A paged snapshot stays private until its
|
|
180
|
+
* terminal poll cursor. Poll groups are staged in order and published as one
|
|
181
|
+
* complete response; invalid input cannot partially replace the prior cut.
|
|
182
|
+
*/
|
|
183
|
+
export class DatabaseSubscriptionView {
|
|
184
|
+
/**
|
|
185
|
+
* @readonly
|
|
186
|
+
* @type {DatabaseViewBinding}
|
|
187
|
+
*/
|
|
188
|
+
binding;
|
|
189
|
+
/**
|
|
190
|
+
* @readonly
|
|
191
|
+
* @type {number}
|
|
192
|
+
*/
|
|
193
|
+
#maximumRows;
|
|
194
|
+
/** @type {Map<string, DatabaseQueryRow>} */
|
|
195
|
+
#rows = new Map();
|
|
196
|
+
/** @type {DatabaseQueryRow[]} */
|
|
197
|
+
#snapshotRows = [];
|
|
198
|
+
/** @type {string | undefined} */
|
|
199
|
+
#snapshotCursor;
|
|
200
|
+
/** @type {Set<string>} */
|
|
201
|
+
#seenSnapshotCursors = new Set();
|
|
202
|
+
/** @type {string | undefined} */
|
|
203
|
+
#pollCursor;
|
|
204
|
+
/** @type {bigint | undefined} */
|
|
205
|
+
#sequence;
|
|
206
|
+
#generation = 0;
|
|
207
|
+
/** @type {DatabaseViewSnapshot | undefined} */
|
|
208
|
+
#snapshot;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {DatabaseScope} scope
|
|
212
|
+
* @param {Pick<DatabaseQueryOptions, "collection" | "predicate" | "limit" | "maximumRows">} options
|
|
213
|
+
*/
|
|
214
|
+
constructor(scope, options) {
|
|
215
|
+
const capturedScope = Object.freeze({
|
|
216
|
+
tenant: requireDatabaseUuid(scope.tenant, "tenant"),
|
|
217
|
+
database: requireDatabaseUuid(scope.database, "database"),
|
|
218
|
+
shard: requireDatabaseU64(scope.shard, "shard"),
|
|
219
|
+
});
|
|
220
|
+
const predicate = predicateCopy(options.predicate);
|
|
221
|
+
const limit = boundedInteger(options.limit, 128, 128, "Snapshot page size");
|
|
222
|
+
encodeDatabaseSubscription({ phase: "snapshot", predicate, limit });
|
|
223
|
+
this.binding = Object.freeze({ scope: capturedScope, collection: requireDatabaseUuid(options.collection, "collection"), predicate, limit });
|
|
224
|
+
this.#maximumRows = boundedInteger(options.maximumRows, 10_000, 1_000_000, "Maximum rows");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @returns {DatabaseViewSnapshot | undefined} */
|
|
228
|
+
getSnapshot() { return this.#snapshot; }
|
|
229
|
+
|
|
230
|
+
/** @returns {DatabaseSubscriptionRequest} */
|
|
231
|
+
getRequest() {
|
|
232
|
+
const { predicate, limit } = this.binding;
|
|
233
|
+
return this.#pollCursor === undefined
|
|
234
|
+
? Object.freeze({ phase: "snapshot", predicate, limit, ...(this.#snapshotCursor === undefined ? {} : { snapshot_cursor: this.#snapshotCursor }) })
|
|
235
|
+
: Object.freeze({ phase: "poll", predicate, poll_cursor: this.#pollCursor });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Discard every opaque position; the next request starts a fresh cut.
|
|
240
|
+
* @returns {void}
|
|
241
|
+
*/
|
|
242
|
+
restart() {
|
|
243
|
+
this.#snapshotRows = [];
|
|
244
|
+
this.#snapshotCursor = undefined;
|
|
245
|
+
this.#seenSnapshotCursors.clear();
|
|
246
|
+
this.#pollCursor = undefined;
|
|
247
|
+
this.#sequence = undefined;
|
|
248
|
+
this.#snapshot = undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {DatabaseSubscriptionResponse} response
|
|
253
|
+
* @returns {DatabaseViewSnapshot | undefined}
|
|
254
|
+
*/
|
|
255
|
+
apply(response) {
|
|
256
|
+
if (this.#pollCursor === undefined) {
|
|
257
|
+
if (response.phase !== "snapshot") throw new DatabaseSubscriptionError("phase_mismatch");
|
|
258
|
+
if (response.rows.length > this.binding.limit) throw new DatabaseSubscriptionError("page_limit_exceeded");
|
|
259
|
+
let previous = this.#snapshotRows.at(-1)?.document ?? "";
|
|
260
|
+
const page = response.rows.map(row => {
|
|
261
|
+
const document = requireDatabaseUuid(row.document, "document");
|
|
262
|
+
if (document <= previous) throw new DatabaseSubscriptionError("snapshot_order_invalid");
|
|
263
|
+
previous = document;
|
|
264
|
+
return Object.freeze({ document, point: pointCopy(row.point, this.binding.scope) });
|
|
265
|
+
});
|
|
266
|
+
if (this.#snapshotRows.length + page.length > this.#maximumRows) throw new DatabaseSubscriptionError("view_limit_exceeded");
|
|
267
|
+
if ("next_snapshot_cursor" in response) {
|
|
268
|
+
const next = cursor(response.next_snapshot_cursor);
|
|
269
|
+
if (this.#seenSnapshotCursors.has(next)) throw new DatabaseSubscriptionError("snapshot_cursor_cycle");
|
|
270
|
+
this.#snapshotRows.push(...page);
|
|
271
|
+
this.#seenSnapshotCursors.add(next);
|
|
272
|
+
this.#snapshotCursor = next;
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
const pollCursor = cursor(response.poll_cursor);
|
|
276
|
+
this.#rows = new Map([...this.#snapshotRows, ...page].map(row => [row.document, row]));
|
|
277
|
+
this.#snapshotRows = [];
|
|
278
|
+
this.#snapshotCursor = undefined;
|
|
279
|
+
this.#seenSnapshotCursors.clear();
|
|
280
|
+
this.#pollCursor = pollCursor;
|
|
281
|
+
this.#generation += 1;
|
|
282
|
+
return this.#publish();
|
|
283
|
+
}
|
|
284
|
+
if (response.phase !== "poll") throw new DatabaseSubscriptionError("phase_mismatch");
|
|
285
|
+
const pollCursor = cursor(response.poll_cursor);
|
|
286
|
+
const unchanged = response.groups.every(group => group.effects.length === 0);
|
|
287
|
+
const rows = unchanged ? this.#rows : new Map(this.#rows);
|
|
288
|
+
let sequence = this.#sequence ?? 0n;
|
|
289
|
+
for (const group of response.groups) {
|
|
290
|
+
if (typeof group.sequence !== "bigint" || group.sequence <= sequence || group.sequence > 18_446_744_073_709_551_615n) {
|
|
291
|
+
throw new DatabaseSubscriptionError("group_order_invalid");
|
|
292
|
+
}
|
|
293
|
+
/** @type {Set<string>} */
|
|
294
|
+
const documents = new Set();
|
|
295
|
+
for (const effect of group.effects) {
|
|
296
|
+
const document = requireDatabaseUuid(effect.document, "document");
|
|
297
|
+
if (documents.has(document)) throw new DatabaseSubscriptionError("duplicate_group_document");
|
|
298
|
+
documents.add(document);
|
|
299
|
+
if (effect.point === null) rows.delete(document);
|
|
300
|
+
else rows.set(document, Object.freeze({ document, point: pointCopy(effect.point, this.binding.scope) }));
|
|
301
|
+
}
|
|
302
|
+
if (rows.size > this.#maximumRows) throw new DatabaseSubscriptionError("view_limit_exceeded");
|
|
303
|
+
// A filtered group may have no matching effects. Its sequence is still
|
|
304
|
+
// a valid advance; the signed response cursor must also be committed.
|
|
305
|
+
sequence = group.sequence;
|
|
306
|
+
}
|
|
307
|
+
this.#rows = rows;
|
|
308
|
+
this.#pollCursor = pollCursor;
|
|
309
|
+
if (response.groups.length > 0) this.#sequence = sequence;
|
|
310
|
+
return this.#publish(unchanged ? this.#snapshot?.rows : undefined);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {readonly DatabaseQueryRow[]} [rows]
|
|
315
|
+
* @returns {DatabaseViewSnapshot}
|
|
316
|
+
*/
|
|
317
|
+
#publish(rows) {
|
|
318
|
+
this.#snapshot = Object.freeze({
|
|
319
|
+
rows: rows ?? Object.freeze([...this.#rows.values()].sort((left, right) => left.document < right.document ? -1 : left.document > right.document ? 1 : 0)),
|
|
320
|
+
position: Object.freeze({ generation: this.#generation, poll_cursor: /** @type {string} */ (this.#pollCursor), ...(this.#sequence === undefined ? {} : { sequence: this.#sequence }) }),
|
|
321
|
+
});
|
|
322
|
+
return this.#snapshot;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @template T
|
|
328
|
+
* @param {Promise<T>} pending
|
|
329
|
+
* @param {AbortSignal} signal
|
|
330
|
+
* @returns {Promise<T>}
|
|
331
|
+
*/
|
|
332
|
+
async function waitFor(pending, signal) {
|
|
333
|
+
signal.throwIfAborted();
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
/** @returns {void} */
|
|
336
|
+
const abort = () => reject(signal.reason);
|
|
337
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
338
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {number} milliseconds
|
|
344
|
+
* @param {AbortSignal} signal
|
|
345
|
+
* @returns {Promise<void>}
|
|
346
|
+
*/
|
|
347
|
+
async function delay(milliseconds, signal) {
|
|
348
|
+
signal.throwIfAborted();
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
/** @returns {void} */
|
|
351
|
+
const abort = () => { clearTimeout(timer); reject(signal.reason); };
|
|
352
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, milliseconds);
|
|
353
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Native snapshot/poll source shared by React, Svelte and TanStack.
|
|
359
|
+
* @param {TerraBaseClient} client
|
|
360
|
+
* @param {DatabaseQueryOptions} options
|
|
361
|
+
* @returns {DatabaseQuerySource}
|
|
362
|
+
*/
|
|
363
|
+
export function createDatabaseQuery(client, options) {
|
|
364
|
+
const template = new DatabaseSubscriptionView(client.scope, options);
|
|
365
|
+
const pollIntervalMs = boundedInteger(options.pollIntervalMs, 1_000, 60_000, "Poll interval");
|
|
366
|
+
const retryDelayMs = boundedInteger(options.retryDelayMs, 250, 60_000, "Retry delay");
|
|
367
|
+
const retryLimit = options.retryLimit ?? 2;
|
|
368
|
+
if (!Number.isInteger(retryLimit) || retryLimit < 0 || retryLimit > 10) throw new TypeError("Retry limit is outside its bounds.");
|
|
369
|
+
const capturedOptions = Object.freeze({ collection: template.binding.collection, predicate: template.binding.predicate, limit: template.binding.limit, ...(options.maximumRows === undefined ? {} : { maximumRows: options.maximumRows }) });
|
|
370
|
+
const binding = template.binding;
|
|
371
|
+
const requestKey = encodeDatabaseSubscription({ phase: "snapshot", predicate: binding.predicate, limit: binding.limit });
|
|
372
|
+
return Object.freeze({
|
|
373
|
+
key: Object.freeze(["terrabase-subscription", binding.scope.tenant, binding.scope.database, binding.scope.shard.toString(), binding.collection, requestKey]),
|
|
374
|
+
binding,
|
|
375
|
+
/**
|
|
376
|
+
* @param {DatabaseQueryCallbacks} observer
|
|
377
|
+
* @param {ReactiveSubscriptionContext} context
|
|
378
|
+
* @returns {() => void}
|
|
379
|
+
*/
|
|
380
|
+
subscribe: (observer, context) => {
|
|
381
|
+
const abort = new AbortController();
|
|
382
|
+
const signal = AbortSignal.any([context.signal, abort.signal]);
|
|
383
|
+
const view = new DatabaseSubscriptionView(binding.scope, capturedOptions);
|
|
384
|
+
/** @returns {Promise<void>} */
|
|
385
|
+
const run = async () => {
|
|
386
|
+
while (!signal.aborted) {
|
|
387
|
+
const request = view.getRequest();
|
|
388
|
+
/** @type {DatabaseResult<DatabaseSubscriptionResponse>} */
|
|
389
|
+
let result;
|
|
390
|
+
let retries = 0;
|
|
391
|
+
while (true) {
|
|
392
|
+
result = await waitFor(client.subscription(binding.collection, request, { signal }), signal);
|
|
393
|
+
signal.throwIfAborted();
|
|
394
|
+
if (result.ok || !["transport_unavailable", "cancelled"].includes(result.error.code) || retries >= retryLimit) break;
|
|
395
|
+
retries += 1;
|
|
396
|
+
await delay(retryDelayMs * retries, signal);
|
|
397
|
+
}
|
|
398
|
+
if (!result.ok) {
|
|
399
|
+
if (result.error.status === 409 && result.error.code === "resync_required") {
|
|
400
|
+
view.restart();
|
|
401
|
+
observer.reset(Object.freeze({ reason: "resync_required", code: result.error.code }));
|
|
402
|
+
await delay(pollIntervalMs, signal);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
throw new DatabaseSubscriptionError(result.error.code, result.error);
|
|
406
|
+
}
|
|
407
|
+
const snapshot = view.apply(result.value);
|
|
408
|
+
if (snapshot !== undefined) {
|
|
409
|
+
await waitFor(Promise.resolve(observer.next(snapshot.rows, snapshot.position)), signal);
|
|
410
|
+
await delay(pollIntervalMs, signal);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
void run().catch(error => {
|
|
415
|
+
if (!signal.aborted) {
|
|
416
|
+
try { observer.error(error); } catch { /* consumer callback failed */ } finally { abort.abort(); }
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
return () => abort.abort();
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|