@terrascale/sdk 0.0.10 → 0.0.11
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/v1/shape-subscription.js +33 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.11 — read replica isolation and freshness
|
|
4
|
+
|
|
5
|
+
Persisted shape replicas bind to their service, project, database, collection and
|
|
6
|
+
filter. Authorization denial clears local documents and freshness; transient
|
|
7
|
+
disconnection marks the view stale while retrying the same checkpoint. Closing
|
|
8
|
+
a subscription settles even when a replica store never finishes loading or saving.
|
|
9
|
+
|
|
3
10
|
## 0.0.10 — production contract fixes
|
|
4
11
|
|
|
5
12
|
The named hosted-auth token operation accepts explicit project API-key grants
|
package/package.json
CHANGED
|
@@ -51,6 +51,15 @@ function deepFreeze(value) {
|
|
|
51
51
|
}
|
|
52
52
|
// setTimeout overflows above 2^31-1 ms and fires at once, so a long Retry-After is waited in chunks.
|
|
53
53
|
const maximumTimerMilliseconds = 2 ** 31 - 1;
|
|
54
|
+
/** @template T @param {T | PromiseLike<T>} operation @param {AbortSignal} signal @returns {Promise<T>} */
|
|
55
|
+
function waitForStore(operation, signal) {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
59
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
60
|
+
void Promise.resolve(operation).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
54
63
|
/** @param {number} milliseconds @param {AbortSignal} signal @returns {Promise<void>} */
|
|
55
64
|
function sleep(milliseconds, signal) {
|
|
56
65
|
return new Promise((resolve, reject) => {
|
|
@@ -92,7 +101,7 @@ export class V1ShapeSubscription {
|
|
|
92
101
|
#onUpdate;
|
|
93
102
|
#replica;
|
|
94
103
|
/** @type {string} */
|
|
95
|
-
#scope;
|
|
104
|
+
#scope = "";
|
|
96
105
|
#external;
|
|
97
106
|
#onExternalAbort = () => this.close();
|
|
98
107
|
#done;
|
|
@@ -117,8 +126,6 @@ export class V1ShapeSubscription {
|
|
|
117
126
|
this.#where = where === undefined ? undefined : requireFilter(parseExactJson(stringifyExactJson(requireFilter(where), "shape filter")));
|
|
118
127
|
this.#onUpdate = onUpdate;
|
|
119
128
|
this.#replica = replica;
|
|
120
|
-
// A replica restores only the identical shape it was saved from.
|
|
121
|
-
this.#scope = stringifyExactJson([this.#params.database, this.#params.collection, this.#where ?? null], "shape scope");
|
|
122
129
|
this.#external = signal;
|
|
123
130
|
if (signal?.aborted === true) this.close();
|
|
124
131
|
else signal?.addEventListener("abort", this.#onExternalAbort);
|
|
@@ -136,11 +143,15 @@ export class V1ShapeSubscription {
|
|
|
136
143
|
async #run() {
|
|
137
144
|
const signal = this.#controller.signal;
|
|
138
145
|
let retries = 0;
|
|
146
|
+
/** @type {import("./shapes.js").V1ShapeRequest | undefined} */
|
|
147
|
+
let retryRequest;
|
|
139
148
|
try {
|
|
149
|
+
// Bind persisted authorization views to the service and project as well as the shape.
|
|
150
|
+
this.#scope = stringifyExactJson([await this.#transport.url("readFreeShape", this.#params, signal), this.#where ?? null], "shape scope");
|
|
140
151
|
if (this.#replica !== undefined) await this.#restore(signal);
|
|
141
152
|
for (;;) {
|
|
142
153
|
signal.throwIfAborted();
|
|
143
|
-
const request = this.#request();
|
|
154
|
+
const request = retryRequest ?? this.#request();
|
|
144
155
|
let page;
|
|
145
156
|
try {
|
|
146
157
|
page = await readShapePage(this.#transport, this.#params, request, signal);
|
|
@@ -148,16 +159,28 @@ export class V1ShapeSubscription {
|
|
|
148
159
|
// Shape reads are replay-safe GETs: a lost connection keeps state and resumes at the same position.
|
|
149
160
|
const lost = error instanceof RequestTransportError || error instanceof BodyTransportError;
|
|
150
161
|
if (lost || (error instanceof TerraBaseV1Error && isRetryable(error))) {
|
|
162
|
+
retryRequest = request;
|
|
163
|
+
if (this.#upToDate) { this.#upToDate = false; this.#onUpdate?.(this); }
|
|
151
164
|
retries = Math.min(retries + 1, maximumBackoffAttempt);
|
|
152
165
|
await sleep(retryDelay(retries, lost ? undefined : error.retryAfter), signal);
|
|
153
166
|
continue;
|
|
154
167
|
}
|
|
168
|
+
retryRequest = undefined;
|
|
155
169
|
if (!(error instanceof TerraBaseV1Error)) throw error;
|
|
170
|
+
if (error.status === 401 || error.status === 403) {
|
|
171
|
+
// Denial takes precedence over callback/storage failures. A stuck store
|
|
172
|
+
// cannot postpone the terminal auth error after local state is cleared.
|
|
173
|
+
try { this.#reset(); } catch { /* preserve the authorization failure */ }
|
|
174
|
+
try { void Promise.resolve(this.#replica?.save(undefined)).catch(() => {}); }
|
|
175
|
+
catch { /* a synchronous store failure must not mask denial either */ }
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
156
178
|
if (error.status !== 409 || error.code !== "must_refetch") throw error;
|
|
157
179
|
this.#recover(request, error);
|
|
158
|
-
if (this.#handle === undefined) await this.#replica?.save(undefined);
|
|
180
|
+
if (this.#handle === undefined) await waitForStore(this.#replica?.save(undefined), signal);
|
|
159
181
|
continue;
|
|
160
182
|
}
|
|
183
|
+
retryRequest = undefined;
|
|
161
184
|
retries = 0;
|
|
162
185
|
this.#commit(page);
|
|
163
186
|
await this.#persist(signal);
|
|
@@ -212,7 +235,8 @@ export class V1ShapeSubscription {
|
|
|
212
235
|
}
|
|
213
236
|
/** @param {AbortSignal} signal */
|
|
214
237
|
async #restore(signal) {
|
|
215
|
-
|
|
238
|
+
signal.throwIfAborted();
|
|
239
|
+
const text = await waitForStore(this.#replica?.load(), signal);
|
|
216
240
|
signal.throwIfAborted();
|
|
217
241
|
if (text === undefined) return;
|
|
218
242
|
/** @type {any} */
|
|
@@ -236,8 +260,9 @@ export class V1ShapeSubscription {
|
|
|
236
260
|
async #persist(signal) {
|
|
237
261
|
// Snapshot pages are not resumable positions; keep the previous replica until the snapshot completes.
|
|
238
262
|
if (this.#replica === undefined || this.#handle === undefined || this.#offset.startsWith("snap.")) return;
|
|
239
|
-
|
|
240
|
-
|
|
263
|
+
signal.throwIfAborted();
|
|
264
|
+
await waitForStore(this.#replica.save(stringifyExactJson({ format: 1, scope: this.#scope, handle: this.#handle, offset: this.#offset,
|
|
265
|
+
cursor: this.#cursor ?? null, resume: this.#resume ?? null, documents: [...this.#documents] }, "shape replica")), signal);
|
|
241
266
|
signal.throwIfAborted();
|
|
242
267
|
}
|
|
243
268
|
/** @param {import("./shapes.js").V1ShapePage} page */
|