@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrascale/sdk",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "JavaScript SDK, typed with JSDoc, for project-scoped C# TerraBase v1 and hosted TerraScale identity.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
- const text = await this.#replica?.load();
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
- await this.#replica.save(stringifyExactJson({ format: 1, scope: this.#scope, handle: this.#handle, offset: this.#offset,
240
- cursor: this.#cursor ?? null, resume: this.#resume ?? null, documents: [...this.#documents] }, "shape replica"));
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 */