@atscript/db-client 0.1.151 → 0.1.153

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/dist/index.cjs CHANGED
@@ -145,6 +145,131 @@ var ActionUnsupportedError = class extends Error {
145
145
  }
146
146
  };
147
147
  //#endregion
148
+ //#region src/meta-store.ts
149
+ /** ETags remembered per store key (most recent first). */
150
+ const CANDIDATES_PER_KEY = 4;
151
+ /**
152
+ * Content-addressed cache of `GET /meta` responses, keyed by the server's
153
+ * `ETag`, used by {@link Client.meta} to revalidate instead of downloading.
154
+ *
155
+ * For each store key (the meta URL, or `ClientOptions.metaKey`) the store
156
+ * remembers the ETags it has seen. `meta()` sends them as `If-None-Match`;
157
+ * when the server answers `304 Not Modified` the client reuses the body
158
+ * stored under the returned `ETag`. Several keys that serve the same bytes
159
+ * — e.g. one parametric controller mounted at `/api/tenant/:id/todos` —
160
+ * therefore share one stored body, and the first visit to a new key costs a
161
+ * `304` instead of the full `/meta`.
162
+ *
163
+ * It is a pure cache: the server decides on every request whether the body
164
+ * is still valid. Failures are never stored.
165
+ *
166
+ * @since 0.1.153
167
+ */
168
+ var MetaStore = class {
169
+ _maxEntries;
170
+ _unconditionalMs;
171
+ /** Raw JSON body by opaque ETag (`W/` stripped — `If-None-Match` compares weakly). */
172
+ _bodies = /* @__PURE__ */ new Map();
173
+ _keys = /* @__PURE__ */ new Map();
174
+ constructor(options) {
175
+ this._maxEntries = Math.max(1, options?.maxEntries ?? 50);
176
+ this._unconditionalMs = Math.max(0, options?.unconditionalMs ?? 3e5);
177
+ }
178
+ /** Number of stored `/meta` bodies. */
179
+ get size() {
180
+ return this._bodies.size;
181
+ }
182
+ /**
183
+ * The ETags this store would send as `If-None-Match` for `key` — those seen
184
+ * for it whose body is still stored, most recent first.
185
+ */
186
+ candidates(key) {
187
+ const entry = this._keys.get(key);
188
+ if (!entry) return [];
189
+ return entry.etags.filter((etag) => this._bodies.has(opaque(etag)));
190
+ }
191
+ /** Drops every stored body and remembered ETag. */
192
+ clear() {
193
+ this._bodies.clear();
194
+ this._keys.clear();
195
+ }
196
+ /**
197
+ * Stored body for `etag` (weak comparison), marking it — and `etag` as the
198
+ * latest candidate for `key` — as recently used.
199
+ *
200
+ * @internal used by `Client`
201
+ */
202
+ _reuse(key, etag) {
203
+ const id = opaque(etag);
204
+ const text = this._bodies.get(id);
205
+ if (text === void 0) return void 0;
206
+ this._bodies.delete(id);
207
+ this._bodies.set(id, text);
208
+ this._remember(key, etag);
209
+ return text;
210
+ }
211
+ /**
212
+ * Stores a `200` body under its ETag and remembers the ETag for `key`.
213
+ *
214
+ * @internal used by `Client`
215
+ */
216
+ _put(key, etag, text) {
217
+ const id = opaque(etag);
218
+ this._bodies.delete(id);
219
+ this._bodies.set(id, text);
220
+ while (this._bodies.size > this._maxEntries) this._bodies.delete(this._bodies.keys().next().value);
221
+ this._remember(key, etag);
222
+ }
223
+ /**
224
+ * Whether `key` is in its no-`If-None-Match` window; an expired window is
225
+ * cleared, so the next request is conditional again.
226
+ *
227
+ * @internal used by `Client`
228
+ */
229
+ _isUnconditional(key) {
230
+ const entry = this._keys.get(key);
231
+ const until = entry?.unconditionalUntil;
232
+ if (until === void 0) return false;
233
+ if (Date.now() < until) return true;
234
+ delete entry.unconditionalUntil;
235
+ return false;
236
+ }
237
+ /** @internal used by `Client` */
238
+ _markUnconditional(key) {
239
+ this._touchKey(key).unconditionalUntil = Date.now() + this._unconditionalMs;
240
+ }
241
+ _remember(key, etag) {
242
+ const entry = this._touchKey(key);
243
+ const id = opaque(etag);
244
+ entry.etags = [etag, ...entry.etags.filter((e) => opaque(e) !== id)].slice(0, CANDIDATES_PER_KEY);
245
+ }
246
+ /** The entry for `key`, created if needed and moved to most recently used. */
247
+ _touchKey(key) {
248
+ const entry = this._keys.get(key) ?? { etags: [] };
249
+ this._keys.delete(key);
250
+ this._keys.set(key, entry);
251
+ while (this._keys.size > this._maxEntries * 4) this._keys.delete(this._keys.keys().next().value);
252
+ return entry;
253
+ }
254
+ };
255
+ function opaque(etag) {
256
+ return etag.startsWith("W/") ? etag.slice(2) : etag;
257
+ }
258
+ /**
259
+ * The store every {@link Client} uses unless `ClientOptions.metaStore` says
260
+ * otherwise, so all clients in a page share revalidated `/meta` bodies.
261
+ */
262
+ const defaultMetaStore = new MetaStore();
263
+ /**
264
+ * Clears the shared default {@link MetaStore}. Call it on sign-out or any
265
+ * identity change (together with `Client.invalidateMeta()` on live clients).
266
+ *
267
+ * @since 0.1.153
268
+ */
269
+ function clearMetaStore() {
270
+ defaultMetaStore.clear();
271
+ }
272
+ //#endregion
148
273
  //#region src/client.ts
149
274
  /**
150
275
  * HTTP client for moost-db REST endpoints.
@@ -185,7 +310,11 @@ var Client = class {
185
310
  _headers;
186
311
  _navigate;
187
312
  _lenientWrites;
313
+ _metaStore;
314
+ _metaKey;
188
315
  _metaPromise;
316
+ /** The `ETag` of the `/meta` body {@link _metaPromise} resolved to. */
317
+ _metaEtag;
189
318
  _validatorPromise;
190
319
  /** Cached deserialized form schemas keyed by resolved URL. */
191
320
  _formCache = /* @__PURE__ */ new Map();
@@ -196,6 +325,8 @@ var Client = class {
196
325
  this._headers = opts?.headers;
197
326
  this._navigate = opts?.navigate;
198
327
  this._lenientWrites = opts?.lenientWrites;
328
+ this._metaStore = opts?.metaStore === false ? void 0 : opts?.metaStore ?? defaultMetaStore;
329
+ this._metaKey = opts?.metaKey;
199
330
  }
200
331
  /**
201
332
  * `GET /query` — query records with typed filter, sort, select, and relations.
@@ -322,10 +453,17 @@ var Client = class {
322
453
  }
323
454
  /**
324
455
  * `GET /meta` — table/view metadata (cached after first call).
456
+ *
457
+ * The response is revalidated through the client's meta store
458
+ * (`ClientOptions.metaStore`): ETags seen before for this store key go out
459
+ * as `If-None-Match`, and a `304 Not Modified` reuses the stored body.
325
460
  */
326
461
  async meta() {
327
462
  if (!this._metaPromise) {
328
- const p = this._request("GET", "meta").catch((err) => {
463
+ const p = this._loadMeta().then(({ body, etag }) => {
464
+ if (this._metaPromise === p) this._metaEtag = etag;
465
+ return body;
466
+ }, (err) => {
329
467
  if (this._metaPromise === p) this._metaPromise = void 0;
330
468
  throw err;
331
469
  });
@@ -334,6 +472,19 @@ var Client = class {
334
472
  return this._metaPromise;
335
473
  }
336
474
  /**
475
+ * The `ETag` of the `/meta` body {@link meta} resolved to — `undefined`
476
+ * before it resolves, after {@link invalidateMeta}, or when the server sent
477
+ * no readable `ETag` (cross-origin without `Access-Control-Expose-Headers:
478
+ * ETag`). Equal ETags mean byte-identical `/meta` bodies, so it can key
479
+ * anything derived from them (deserialized types, validators, column
480
+ * definitions) across clients and route params.
481
+ *
482
+ * @since 0.1.153
483
+ */
484
+ metaEtag() {
485
+ return this._metaEtag;
486
+ }
487
+ /**
337
488
  * Drops everything this client cached from the server's metadata — the
338
489
  * `/meta` response, the validator built from it and the loaded action
339
490
  * forms — so the next call fetches them again. Call it when the viewer's
@@ -341,10 +492,15 @@ var Client = class {
341
492
  * user, and a client reused across identities would otherwise keep serving
342
493
  * the previous user's columns, actions and write rules.
343
494
  *
495
+ * The next `meta()` still revalidates through the meta store, so an
496
+ * unchanged `/meta` costs a `304`, not a download. On an identity change
497
+ * also clear the store (`clearMetaStore()` / `metaStore.clear()`).
498
+ *
344
499
  * @since 0.1.151
345
500
  */
346
501
  invalidateMeta() {
347
502
  this._metaPromise = void 0;
503
+ this._metaEtag = void 0;
348
504
  this._validatorPromise = void 0;
349
505
  this._formCache.clear();
350
506
  }
@@ -600,6 +756,83 @@ var Client = class {
600
756
  if (typeof this._headers === "function") return await this._headers();
601
757
  return this._headers;
602
758
  }
759
+ /**
760
+ * `GET /meta` through the meta store: conditional when the store holds
761
+ * bodies for this key, falling back to a plain request whenever the
762
+ * conditional one cannot be answered from the store.
763
+ */
764
+ async _loadMeta() {
765
+ const url = this._endpointUrl("meta");
766
+ const store = this._metaStore;
767
+ if (!store) {
768
+ const res = await this._fetchUrl(url, {
769
+ method: "GET",
770
+ headers: await this._resolveHeaders()
771
+ });
772
+ return {
773
+ body: await this._readResponse(res, url, "GET", false),
774
+ etag: res.ok ? readEtag(res) : void 0
775
+ };
776
+ }
777
+ const key = this._metaKey ?? url;
778
+ const headers = await this._resolveHeaders();
779
+ const candidates = store._isUnconditional(key) ? [] : store.candidates(key);
780
+ if (candidates.length > 0) {
781
+ const init = {
782
+ method: "GET",
783
+ headers: {
784
+ ...headers,
785
+ "If-None-Match": candidates.join(", ")
786
+ }
787
+ };
788
+ let res;
789
+ try {
790
+ res = await this._fetchUrl(url, init);
791
+ } catch {}
792
+ if (res && res.status === 304) {
793
+ const etag = readEtag(res);
794
+ const text = etag === void 0 ? void 0 : store._reuse(key, etag);
795
+ if (text !== void 0) return {
796
+ body: JSON.parse(text),
797
+ etag
798
+ };
799
+ } else if (res) return this._storeMeta(store, key, url, res);
800
+ else {
801
+ const fallback = await this._fetchUrl(url, {
802
+ method: "GET",
803
+ headers: { ...headers }
804
+ });
805
+ store._markUnconditional(key);
806
+ return this._storeMeta(store, key, url, fallback);
807
+ }
808
+ }
809
+ const res = await this._fetchUrl(url, {
810
+ method: "GET",
811
+ headers: { ...headers }
812
+ });
813
+ return this._storeMeta(store, key, url, res);
814
+ }
815
+ /** Reads a `/meta` response; a `2xx` carrying a readable `ETag` goes into the store. */
816
+ async _storeMeta(store, key, url, res) {
817
+ const etag = res.ok ? readEtag(res) : void 0;
818
+ if (etag === void 0 || typeof res.text !== "function") return {
819
+ body: await this._readResponse(res, url, "GET", false),
820
+ etag
821
+ };
822
+ let body;
823
+ let text;
824
+ try {
825
+ text = await res.text();
826
+ body = JSON.parse(text);
827
+ } catch (cause) {
828
+ throw new TransportError("GET", url, `HTTP ${res.status} body is not JSON (${describeCause(cause)})`, cause);
829
+ }
830
+ store._put(key, etag, text);
831
+ return {
832
+ body,
833
+ etag
834
+ };
835
+ }
603
836
  _request(method, endpoint, body) {
604
837
  return this._requestUrl(method, this._endpointUrl(endpoint), body);
605
838
  }
@@ -622,13 +855,19 @@ var Client = class {
622
855
  return this._send(url, init, allowEmpty);
623
856
  }
624
857
  async _send(url, init, allowEmpty) {
625
- const method = init.method ?? "GET";
626
- let res;
858
+ const res = await this._fetchUrl(url, init);
859
+ return this._readResponse(res, url, init.method ?? "GET", allowEmpty);
860
+ }
861
+ /** `fetch` with a network failure surfaced as {@link TransportError}. */
862
+ async _fetchUrl(url, init) {
627
863
  try {
628
- res = await this._fetch(url, init);
864
+ return await this._fetch(url, init);
629
865
  } catch (cause) {
630
- throw new TransportError(method, url, describeCause(cause), cause);
866
+ throw new TransportError(init.method ?? "GET", url, describeCause(cause), cause);
631
867
  }
868
+ }
869
+ /** A response's JSON body, or the matching error for a non-2xx status. */
870
+ async _readResponse(res, url, method, allowEmpty) {
632
871
  if (!res.ok) {
633
872
  let errorBody;
634
873
  try {
@@ -657,6 +896,13 @@ var Client = class {
657
896
  }
658
897
  }
659
898
  };
899
+ /**
900
+ * The response's `ETag`, when readable — a cross-origin response hides it
901
+ * unless the server sends `Access-Control-Expose-Headers: ETag`.
902
+ */
903
+ function readEtag(res) {
904
+ return res.headers?.get?.("etag") || void 0;
905
+ }
660
906
  /** One-line rendering of a thrown value for a `TransportError` message. */
661
907
  function describeCause(cause) {
662
908
  if (cause instanceof Error) return cause.message || cause.name;
@@ -758,6 +1004,7 @@ exports.ActionTargetError = ActionTargetError;
758
1004
  exports.ActionUnsupportedError = ActionUnsupportedError;
759
1005
  exports.Client = Client;
760
1006
  exports.ClientError = ClientError;
1007
+ exports.MetaStore = MetaStore;
761
1008
  exports.TransportError = TransportError;
762
1009
  exports.VersionMismatchError = VersionMismatchError;
763
1010
  exports.actionIdentifier = actionIdentifier;
@@ -773,6 +1020,7 @@ Object.defineProperty(exports, "bucketStartInstant", {
773
1020
  return _uniqu_core.bucketStartInstant;
774
1021
  }
775
1022
  });
1023
+ exports.clearMetaStore = clearMetaStore;
776
1024
  exports.encodeNavigateId = encodeNavigateId;
777
1025
  exports.formatIdentifier = formatIdentifier;
778
1026
  exports.formatIdentifierField = formatIdentifierField;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as SearchIndexInfo, B as Uniquery, C as MetaResponse, D as PatchOf, E as PageResult, F as TDbInsertManyResult, H as ValidGroupBy, I as TDbInsertResult, L as TDbQueryTarget, M as TDbDeleteResult, N as TDbInsertIgnoreResult, O as RelationInfo, P as TDbInsertManyIgnoreResult, R as TDbUpdateResult, S as IdOf, T as OwnOf, U as WeekStart, V as UniqueryControls, _ as DbRow, a as ValidatorMode, b as FieldMeta, c as AggregateResult, d as BucketUnit, f as CalendarBucketLabel, g as DbPatch, h as DataOf, j as ServerError, k as RowOf, l as AtscriptClientShape, m as ClientResponse, n as ClientValidator, p as ClientOptions, r as ClientValidatorOptions, s as AggregateQuery, t as ClientValidationError, u as BucketExpr, v as DecoratedControls, w as NavOf, x as FilterExpr, y as DecoratedQuery, z as TypedWithRelation } from "./validator-CVM7eNP7.cjs";
1
+ import { A as SearchIndexInfo, B as Uniquery, C as MetaResponse, D as PatchOf, E as PageResult, F as TDbInsertManyResult, G as MetaStoreOptions, H as ValidGroupBy, I as TDbInsertResult, K as clearMetaStore, L as TDbQueryTarget, M as TDbDeleteResult, N as TDbInsertIgnoreResult, O as RelationInfo, P as TDbInsertManyIgnoreResult, R as TDbUpdateResult, S as IdOf, T as OwnOf, U as WeekStart, V as UniqueryControls, W as MetaStore, _ as DbRow, a as ValidatorMode, b as FieldMeta, c as AggregateResult, d as BucketUnit, f as CalendarBucketLabel, g as DbPatch, h as DataOf, j as ServerError, k as RowOf, l as AtscriptClientShape, m as ClientResponse, n as ClientValidator, p as ClientOptions, r as ClientValidatorOptions, s as AggregateQuery, t as ClientValidationError, u as BucketExpr, v as DecoratedControls, w as NavOf, x as FilterExpr, y as DecoratedQuery, z as TypedWithRelation } from "./validator-u-SCH8c3.cjs";
2
2
  import { AggregateExpr, AggregateOfExpr, AggregateQuery as AggregateQuery$1, AggregateResult as AggregateResult$1, BucketExpr as BucketExpr$1, BucketSeriesOptions, NextBucketOptions, SelectArithExpr, Uniquery as Uniquery$1, ValidGroupBy as ValidGroupBy$1, bucketSeries, bucketStartInstant, nextBucketLabel } from "@uniqu/core";
3
3
  import { TAtscriptAnnotatedType, TSerializedAnnotatedType } from "@atscript/typescript/utils";
4
4
  import { TCrudOp, TCrudPermissions, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionLevel, TDbActionProcessor, TDbActionTargetSummary, TDbActionTargetSummary as TDbActionTargetSummary$1, TDbAvailableActions, TDbAvailableActions as TDbAvailableActions$1, TDbDeleteResult as TDbDeleteResult$1, TDbInsertIgnoreResult as TDbInsertIgnoreResult$1, TDbInsertManyIgnoreResult as TDbInsertManyIgnoreResult$1, TDbInsertManyResult as TDbInsertManyResult$1, TDbInsertResult as TDbInsertResult$1, TDbUpdateResult as TDbUpdateResult$1 } from "@atscript/db";
@@ -49,7 +49,11 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
49
49
  private readonly _headers?;
50
50
  private readonly _navigate?;
51
51
  private readonly _lenientWrites?;
52
+ private readonly _metaStore;
53
+ private readonly _metaKey?;
52
54
  private _metaPromise?;
55
+ /** The `ETag` of the `/meta` body {@link _metaPromise} resolved to. */
56
+ private _metaEtag?;
53
57
  private _validatorPromise?;
54
58
  /** Cached deserialized form schemas keyed by resolved URL. */
55
59
  private _formCache;
@@ -163,8 +167,23 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
163
167
  remove(id: Id<T>): Promise<TDbDeleteResult$1>;
164
168
  /**
165
169
  * `GET /meta` — table/view metadata (cached after first call).
170
+ *
171
+ * The response is revalidated through the client's meta store
172
+ * (`ClientOptions.metaStore`): ETags seen before for this store key go out
173
+ * as `If-None-Match`, and a `304 Not Modified` reuses the stored body.
166
174
  */
167
175
  meta(): Promise<MetaResponse>;
176
+ /**
177
+ * The `ETag` of the `/meta` body {@link meta} resolved to — `undefined`
178
+ * before it resolves, after {@link invalidateMeta}, or when the server sent
179
+ * no readable `ETag` (cross-origin without `Access-Control-Expose-Headers:
180
+ * ETag`). Equal ETags mean byte-identical `/meta` bodies, so it can key
181
+ * anything derived from them (deserialized types, validators, column
182
+ * definitions) across clients and route params.
183
+ *
184
+ * @since 0.1.153
185
+ */
186
+ metaEtag(): string | undefined;
168
187
  /**
169
188
  * Drops everything this client cached from the server's metadata — the
170
189
  * `/meta` response, the validator built from it and the loaded action
@@ -173,6 +192,10 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
173
192
  * user, and a client reused across identities would otherwise keep serving
174
193
  * the previous user's columns, actions and write rules.
175
194
  *
195
+ * The next `meta()` still revalidates through the meta store, so an
196
+ * unchanged `/meta` costs a `304`, not a download. On an identity change
197
+ * also clear the store (`clearMetaStore()` / `metaStore.clear()`).
198
+ *
176
199
  * @since 0.1.151
177
200
  */
178
201
  invalidateMeta(): void;
@@ -299,12 +322,24 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
299
322
  private _getOrNull;
300
323
  private _get;
301
324
  private _resolveHeaders;
325
+ /**
326
+ * `GET /meta` through the meta store: conditional when the store holds
327
+ * bodies for this key, falling back to a plain request whenever the
328
+ * conditional one cannot be answered from the store.
329
+ */
330
+ private _loadMeta;
331
+ /** Reads a `/meta` response; a `2xx` carrying a readable `ETag` goes into the store. */
332
+ private _storeMeta;
302
333
  private _request;
303
334
  /** `baseUrl + path` joined with an endpoint relative to this controller (`""`, `meta`, `?q`…). */
304
335
  private _endpointUrl;
305
336
  /** Sends one request to an absolute URL with the client's headers and a JSON body. */
306
337
  private _requestUrl;
307
338
  private _send;
339
+ /** `fetch` with a network failure surfaced as {@link TransportError}. */
340
+ private _fetchUrl;
341
+ /** A response's JSON body, or the matching error for a non-2xx status. */
342
+ private _readResponse;
308
343
  }
309
344
  /**
310
345
  * The identifier `action` takes for one row (or identifier) of the
@@ -508,4 +543,4 @@ declare class ActionUnsupportedError extends Error {
508
543
  constructor(action: string, processor: string, message: string);
509
544
  }
510
545
  //#endregion
511
- export { ActionDisabledError, type ActionDisabledErrorBody, ActionNotFoundError, ActionTargetError, type ActionTargetErrorBody, ActionUnsupportedError, type AggregateQuery, type AggregateResult, type AtscriptClientShape, type BucketExpr, type BucketSeriesOptions, type BucketUnit, type CalendarBucketLabel, Client, ClientError, type ClientOptions, type ClientResponse, type ClientValidationError, type ClientValidator, type ClientValidatorOptions, type DataOf, type DbPatch, type DbRow, type FieldMeta, type FilterExpr, type IdOf, type MetaResponse, type NavOf, type NextBucketOptions, type OwnOf, type PageResult, type PatchOf, type RelationInfo, type RowOf, type SearchIndexInfo, type ServerError, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionTargetSummary, type TDbAvailableActions, type TDbDeleteResult, type TDbInsertIgnoreResult, type TDbInsertManyIgnoreResult, type TDbInsertManyResult, type TDbInsertResult, type TDbQueryTarget, type TDbUpdateResult, type TSerializedAnnotatedType, TransportError, type TypedWithRelation, type Uniquery, type UniqueryControls, type ValidGroupBy, type ValidatorMode, VersionMismatchError, type VersionMismatchErrorBody, type WeekStart, actionIdentifier, bucketSeries, bucketStartInstant, encodeNavigateId, formatIdentifier, formatIdentifierField, nextBucketLabel };
546
+ export { ActionDisabledError, type ActionDisabledErrorBody, ActionNotFoundError, ActionTargetError, type ActionTargetErrorBody, ActionUnsupportedError, type AggregateQuery, type AggregateResult, type AtscriptClientShape, type BucketExpr, type BucketSeriesOptions, type BucketUnit, type CalendarBucketLabel, Client, ClientError, type ClientOptions, type ClientResponse, type ClientValidationError, type ClientValidator, type ClientValidatorOptions, type DataOf, type DbPatch, type DbRow, type FieldMeta, type FilterExpr, type IdOf, type MetaResponse, MetaStore, type MetaStoreOptions, type NavOf, type NextBucketOptions, type OwnOf, type PageResult, type PatchOf, type RelationInfo, type RowOf, type SearchIndexInfo, type ServerError, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionTargetSummary, type TDbAvailableActions, type TDbDeleteResult, type TDbInsertIgnoreResult, type TDbInsertManyIgnoreResult, type TDbInsertManyResult, type TDbInsertResult, type TDbQueryTarget, type TDbUpdateResult, type TSerializedAnnotatedType, TransportError, type TypedWithRelation, type Uniquery, type UniqueryControls, type ValidGroupBy, type ValidatorMode, VersionMismatchError, type VersionMismatchErrorBody, type WeekStart, actionIdentifier, bucketSeries, bucketStartInstant, clearMetaStore, encodeNavigateId, formatIdentifier, formatIdentifierField, nextBucketLabel };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as SearchIndexInfo, B as Uniquery, C as MetaResponse, D as PatchOf, E as PageResult, F as TDbInsertManyResult, H as ValidGroupBy, I as TDbInsertResult, L as TDbQueryTarget, M as TDbDeleteResult, N as TDbInsertIgnoreResult, O as RelationInfo, P as TDbInsertManyIgnoreResult, R as TDbUpdateResult, S as IdOf, T as OwnOf, U as WeekStart, V as UniqueryControls, _ as DbRow, a as ValidatorMode, b as FieldMeta, c as AggregateResult, d as BucketUnit, f as CalendarBucketLabel, g as DbPatch, h as DataOf, j as ServerError, k as RowOf, l as AtscriptClientShape, m as ClientResponse, n as ClientValidator, p as ClientOptions, r as ClientValidatorOptions, s as AggregateQuery, t as ClientValidationError, u as BucketExpr, v as DecoratedControls, w as NavOf, x as FilterExpr, y as DecoratedQuery, z as TypedWithRelation } from "./validator-CPmmXTAt.mjs";
1
+ import { A as SearchIndexInfo, B as Uniquery, C as MetaResponse, D as PatchOf, E as PageResult, F as TDbInsertManyResult, G as MetaStoreOptions, H as ValidGroupBy, I as TDbInsertResult, K as clearMetaStore, L as TDbQueryTarget, M as TDbDeleteResult, N as TDbInsertIgnoreResult, O as RelationInfo, P as TDbInsertManyIgnoreResult, R as TDbUpdateResult, S as IdOf, T as OwnOf, U as WeekStart, V as UniqueryControls, W as MetaStore, _ as DbRow, a as ValidatorMode, b as FieldMeta, c as AggregateResult, d as BucketUnit, f as CalendarBucketLabel, g as DbPatch, h as DataOf, j as ServerError, k as RowOf, l as AtscriptClientShape, m as ClientResponse, n as ClientValidator, p as ClientOptions, r as ClientValidatorOptions, s as AggregateQuery, t as ClientValidationError, u as BucketExpr, v as DecoratedControls, w as NavOf, x as FilterExpr, y as DecoratedQuery, z as TypedWithRelation } from "./validator-DRW4WDEX.mjs";
2
2
  import { TAtscriptAnnotatedType, TSerializedAnnotatedType } from "@atscript/typescript/utils";
3
3
  import { AggregateExpr, AggregateOfExpr, AggregateQuery as AggregateQuery$1, AggregateResult as AggregateResult$1, BucketExpr as BucketExpr$1, BucketSeriesOptions, NextBucketOptions, SelectArithExpr, Uniquery as Uniquery$1, ValidGroupBy as ValidGroupBy$1, bucketSeries, bucketStartInstant, nextBucketLabel } from "@uniqu/core";
4
4
  import { TCrudOp, TCrudPermissions, TDbActionInfo, TDbActionInfo as TDbActionInfo$1, TDbActionIntent, TDbActionLevel, TDbActionProcessor, TDbActionTargetSummary, TDbActionTargetSummary as TDbActionTargetSummary$1, TDbAvailableActions, TDbAvailableActions as TDbAvailableActions$1, TDbDeleteResult as TDbDeleteResult$1, TDbInsertIgnoreResult as TDbInsertIgnoreResult$1, TDbInsertManyIgnoreResult as TDbInsertManyIgnoreResult$1, TDbInsertManyResult as TDbInsertManyResult$1, TDbInsertResult as TDbInsertResult$1, TDbUpdateResult as TDbUpdateResult$1 } from "@atscript/db";
@@ -49,7 +49,11 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
49
49
  private readonly _headers?;
50
50
  private readonly _navigate?;
51
51
  private readonly _lenientWrites?;
52
+ private readonly _metaStore;
53
+ private readonly _metaKey?;
52
54
  private _metaPromise?;
55
+ /** The `ETag` of the `/meta` body {@link _metaPromise} resolved to. */
56
+ private _metaEtag?;
53
57
  private _validatorPromise?;
54
58
  /** Cached deserialized form schemas keyed by resolved URL. */
55
59
  private _formCache;
@@ -163,8 +167,23 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
163
167
  remove(id: Id<T>): Promise<TDbDeleteResult$1>;
164
168
  /**
165
169
  * `GET /meta` — table/view metadata (cached after first call).
170
+ *
171
+ * The response is revalidated through the client's meta store
172
+ * (`ClientOptions.metaStore`): ETags seen before for this store key go out
173
+ * as `If-None-Match`, and a `304 Not Modified` reuses the stored body.
166
174
  */
167
175
  meta(): Promise<MetaResponse>;
176
+ /**
177
+ * The `ETag` of the `/meta` body {@link meta} resolved to — `undefined`
178
+ * before it resolves, after {@link invalidateMeta}, or when the server sent
179
+ * no readable `ETag` (cross-origin without `Access-Control-Expose-Headers:
180
+ * ETag`). Equal ETags mean byte-identical `/meta` bodies, so it can key
181
+ * anything derived from them (deserialized types, validators, column
182
+ * definitions) across clients and route params.
183
+ *
184
+ * @since 0.1.153
185
+ */
186
+ metaEtag(): string | undefined;
168
187
  /**
169
188
  * Drops everything this client cached from the server's metadata — the
170
189
  * `/meta` response, the validator built from it and the loaded action
@@ -173,6 +192,10 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
173
192
  * user, and a client reused across identities would otherwise keep serving
174
193
  * the previous user's columns, actions and write rules.
175
194
  *
195
+ * The next `meta()` still revalidates through the meta store, so an
196
+ * unchanged `/meta` costs a `304`, not a download. On an identity change
197
+ * also clear the store (`clearMetaStore()` / `metaStore.clear()`).
198
+ *
176
199
  * @since 0.1.151
177
200
  */
178
201
  invalidateMeta(): void;
@@ -299,12 +322,24 @@ declare class Client<T extends AtscriptClientShape = AtscriptClientShape, D exte
299
322
  private _getOrNull;
300
323
  private _get;
301
324
  private _resolveHeaders;
325
+ /**
326
+ * `GET /meta` through the meta store: conditional when the store holds
327
+ * bodies for this key, falling back to a plain request whenever the
328
+ * conditional one cannot be answered from the store.
329
+ */
330
+ private _loadMeta;
331
+ /** Reads a `/meta` response; a `2xx` carrying a readable `ETag` goes into the store. */
332
+ private _storeMeta;
302
333
  private _request;
303
334
  /** `baseUrl + path` joined with an endpoint relative to this controller (`""`, `meta`, `?q`…). */
304
335
  private _endpointUrl;
305
336
  /** Sends one request to an absolute URL with the client's headers and a JSON body. */
306
337
  private _requestUrl;
307
338
  private _send;
339
+ /** `fetch` with a network failure surfaced as {@link TransportError}. */
340
+ private _fetchUrl;
341
+ /** A response's JSON body, or the matching error for a non-2xx status. */
342
+ private _readResponse;
308
343
  }
309
344
  /**
310
345
  * The identifier `action` takes for one row (or identifier) of the
@@ -508,4 +543,4 @@ declare class ActionUnsupportedError extends Error {
508
543
  constructor(action: string, processor: string, message: string);
509
544
  }
510
545
  //#endregion
511
- export { ActionDisabledError, type ActionDisabledErrorBody, ActionNotFoundError, ActionTargetError, type ActionTargetErrorBody, ActionUnsupportedError, type AggregateQuery, type AggregateResult, type AtscriptClientShape, type BucketExpr, type BucketSeriesOptions, type BucketUnit, type CalendarBucketLabel, Client, ClientError, type ClientOptions, type ClientResponse, type ClientValidationError, type ClientValidator, type ClientValidatorOptions, type DataOf, type DbPatch, type DbRow, type FieldMeta, type FilterExpr, type IdOf, type MetaResponse, type NavOf, type NextBucketOptions, type OwnOf, type PageResult, type PatchOf, type RelationInfo, type RowOf, type SearchIndexInfo, type ServerError, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionTargetSummary, type TDbAvailableActions, type TDbDeleteResult, type TDbInsertIgnoreResult, type TDbInsertManyIgnoreResult, type TDbInsertManyResult, type TDbInsertResult, type TDbQueryTarget, type TDbUpdateResult, type TSerializedAnnotatedType, TransportError, type TypedWithRelation, type Uniquery, type UniqueryControls, type ValidGroupBy, type ValidatorMode, VersionMismatchError, type VersionMismatchErrorBody, type WeekStart, actionIdentifier, bucketSeries, bucketStartInstant, encodeNavigateId, formatIdentifier, formatIdentifierField, nextBucketLabel };
546
+ export { ActionDisabledError, type ActionDisabledErrorBody, ActionNotFoundError, ActionTargetError, type ActionTargetErrorBody, ActionUnsupportedError, type AggregateQuery, type AggregateResult, type AtscriptClientShape, type BucketExpr, type BucketSeriesOptions, type BucketUnit, type CalendarBucketLabel, Client, ClientError, type ClientOptions, type ClientResponse, type ClientValidationError, type ClientValidator, type ClientValidatorOptions, type DataOf, type DbPatch, type DbRow, type FieldMeta, type FilterExpr, type IdOf, type MetaResponse, MetaStore, type MetaStoreOptions, type NavOf, type NextBucketOptions, type OwnOf, type PageResult, type PatchOf, type RelationInfo, type RowOf, type SearchIndexInfo, type ServerError, type TCrudOp, type TCrudPermissions, type TDbActionInfo, type TDbActionIntent, type TDbActionLevel, type TDbActionProcessor, type TDbActionTargetSummary, type TDbAvailableActions, type TDbDeleteResult, type TDbInsertIgnoreResult, type TDbInsertManyIgnoreResult, type TDbInsertManyResult, type TDbInsertResult, type TDbQueryTarget, type TDbUpdateResult, type TSerializedAnnotatedType, TransportError, type TypedWithRelation, type Uniquery, type UniqueryControls, type ValidGroupBy, type ValidatorMode, VersionMismatchError, type VersionMismatchErrorBody, type WeekStart, actionIdentifier, bucketSeries, bucketStartInstant, clearMetaStore, encodeNavigateId, formatIdentifier, formatIdentifierField, nextBucketLabel };
package/dist/index.mjs CHANGED
@@ -144,6 +144,131 @@ var ActionUnsupportedError = class extends Error {
144
144
  }
145
145
  };
146
146
  //#endregion
147
+ //#region src/meta-store.ts
148
+ /** ETags remembered per store key (most recent first). */
149
+ const CANDIDATES_PER_KEY = 4;
150
+ /**
151
+ * Content-addressed cache of `GET /meta` responses, keyed by the server's
152
+ * `ETag`, used by {@link Client.meta} to revalidate instead of downloading.
153
+ *
154
+ * For each store key (the meta URL, or `ClientOptions.metaKey`) the store
155
+ * remembers the ETags it has seen. `meta()` sends them as `If-None-Match`;
156
+ * when the server answers `304 Not Modified` the client reuses the body
157
+ * stored under the returned `ETag`. Several keys that serve the same bytes
158
+ * — e.g. one parametric controller mounted at `/api/tenant/:id/todos` —
159
+ * therefore share one stored body, and the first visit to a new key costs a
160
+ * `304` instead of the full `/meta`.
161
+ *
162
+ * It is a pure cache: the server decides on every request whether the body
163
+ * is still valid. Failures are never stored.
164
+ *
165
+ * @since 0.1.153
166
+ */
167
+ var MetaStore = class {
168
+ _maxEntries;
169
+ _unconditionalMs;
170
+ /** Raw JSON body by opaque ETag (`W/` stripped — `If-None-Match` compares weakly). */
171
+ _bodies = /* @__PURE__ */ new Map();
172
+ _keys = /* @__PURE__ */ new Map();
173
+ constructor(options) {
174
+ this._maxEntries = Math.max(1, options?.maxEntries ?? 50);
175
+ this._unconditionalMs = Math.max(0, options?.unconditionalMs ?? 3e5);
176
+ }
177
+ /** Number of stored `/meta` bodies. */
178
+ get size() {
179
+ return this._bodies.size;
180
+ }
181
+ /**
182
+ * The ETags this store would send as `If-None-Match` for `key` — those seen
183
+ * for it whose body is still stored, most recent first.
184
+ */
185
+ candidates(key) {
186
+ const entry = this._keys.get(key);
187
+ if (!entry) return [];
188
+ return entry.etags.filter((etag) => this._bodies.has(opaque(etag)));
189
+ }
190
+ /** Drops every stored body and remembered ETag. */
191
+ clear() {
192
+ this._bodies.clear();
193
+ this._keys.clear();
194
+ }
195
+ /**
196
+ * Stored body for `etag` (weak comparison), marking it — and `etag` as the
197
+ * latest candidate for `key` — as recently used.
198
+ *
199
+ * @internal used by `Client`
200
+ */
201
+ _reuse(key, etag) {
202
+ const id = opaque(etag);
203
+ const text = this._bodies.get(id);
204
+ if (text === void 0) return void 0;
205
+ this._bodies.delete(id);
206
+ this._bodies.set(id, text);
207
+ this._remember(key, etag);
208
+ return text;
209
+ }
210
+ /**
211
+ * Stores a `200` body under its ETag and remembers the ETag for `key`.
212
+ *
213
+ * @internal used by `Client`
214
+ */
215
+ _put(key, etag, text) {
216
+ const id = opaque(etag);
217
+ this._bodies.delete(id);
218
+ this._bodies.set(id, text);
219
+ while (this._bodies.size > this._maxEntries) this._bodies.delete(this._bodies.keys().next().value);
220
+ this._remember(key, etag);
221
+ }
222
+ /**
223
+ * Whether `key` is in its no-`If-None-Match` window; an expired window is
224
+ * cleared, so the next request is conditional again.
225
+ *
226
+ * @internal used by `Client`
227
+ */
228
+ _isUnconditional(key) {
229
+ const entry = this._keys.get(key);
230
+ const until = entry?.unconditionalUntil;
231
+ if (until === void 0) return false;
232
+ if (Date.now() < until) return true;
233
+ delete entry.unconditionalUntil;
234
+ return false;
235
+ }
236
+ /** @internal used by `Client` */
237
+ _markUnconditional(key) {
238
+ this._touchKey(key).unconditionalUntil = Date.now() + this._unconditionalMs;
239
+ }
240
+ _remember(key, etag) {
241
+ const entry = this._touchKey(key);
242
+ const id = opaque(etag);
243
+ entry.etags = [etag, ...entry.etags.filter((e) => opaque(e) !== id)].slice(0, CANDIDATES_PER_KEY);
244
+ }
245
+ /** The entry for `key`, created if needed and moved to most recently used. */
246
+ _touchKey(key) {
247
+ const entry = this._keys.get(key) ?? { etags: [] };
248
+ this._keys.delete(key);
249
+ this._keys.set(key, entry);
250
+ while (this._keys.size > this._maxEntries * 4) this._keys.delete(this._keys.keys().next().value);
251
+ return entry;
252
+ }
253
+ };
254
+ function opaque(etag) {
255
+ return etag.startsWith("W/") ? etag.slice(2) : etag;
256
+ }
257
+ /**
258
+ * The store every {@link Client} uses unless `ClientOptions.metaStore` says
259
+ * otherwise, so all clients in a page share revalidated `/meta` bodies.
260
+ */
261
+ const defaultMetaStore = new MetaStore();
262
+ /**
263
+ * Clears the shared default {@link MetaStore}. Call it on sign-out or any
264
+ * identity change (together with `Client.invalidateMeta()` on live clients).
265
+ *
266
+ * @since 0.1.153
267
+ */
268
+ function clearMetaStore() {
269
+ defaultMetaStore.clear();
270
+ }
271
+ //#endregion
147
272
  //#region src/client.ts
148
273
  /**
149
274
  * HTTP client for moost-db REST endpoints.
@@ -184,7 +309,11 @@ var Client = class {
184
309
  _headers;
185
310
  _navigate;
186
311
  _lenientWrites;
312
+ _metaStore;
313
+ _metaKey;
187
314
  _metaPromise;
315
+ /** The `ETag` of the `/meta` body {@link _metaPromise} resolved to. */
316
+ _metaEtag;
188
317
  _validatorPromise;
189
318
  /** Cached deserialized form schemas keyed by resolved URL. */
190
319
  _formCache = /* @__PURE__ */ new Map();
@@ -195,6 +324,8 @@ var Client = class {
195
324
  this._headers = opts?.headers;
196
325
  this._navigate = opts?.navigate;
197
326
  this._lenientWrites = opts?.lenientWrites;
327
+ this._metaStore = opts?.metaStore === false ? void 0 : opts?.metaStore ?? defaultMetaStore;
328
+ this._metaKey = opts?.metaKey;
198
329
  }
199
330
  /**
200
331
  * `GET /query` — query records with typed filter, sort, select, and relations.
@@ -321,10 +452,17 @@ var Client = class {
321
452
  }
322
453
  /**
323
454
  * `GET /meta` — table/view metadata (cached after first call).
455
+ *
456
+ * The response is revalidated through the client's meta store
457
+ * (`ClientOptions.metaStore`): ETags seen before for this store key go out
458
+ * as `If-None-Match`, and a `304 Not Modified` reuses the stored body.
324
459
  */
325
460
  async meta() {
326
461
  if (!this._metaPromise) {
327
- const p = this._request("GET", "meta").catch((err) => {
462
+ const p = this._loadMeta().then(({ body, etag }) => {
463
+ if (this._metaPromise === p) this._metaEtag = etag;
464
+ return body;
465
+ }, (err) => {
328
466
  if (this._metaPromise === p) this._metaPromise = void 0;
329
467
  throw err;
330
468
  });
@@ -333,6 +471,19 @@ var Client = class {
333
471
  return this._metaPromise;
334
472
  }
335
473
  /**
474
+ * The `ETag` of the `/meta` body {@link meta} resolved to — `undefined`
475
+ * before it resolves, after {@link invalidateMeta}, or when the server sent
476
+ * no readable `ETag` (cross-origin without `Access-Control-Expose-Headers:
477
+ * ETag`). Equal ETags mean byte-identical `/meta` bodies, so it can key
478
+ * anything derived from them (deserialized types, validators, column
479
+ * definitions) across clients and route params.
480
+ *
481
+ * @since 0.1.153
482
+ */
483
+ metaEtag() {
484
+ return this._metaEtag;
485
+ }
486
+ /**
336
487
  * Drops everything this client cached from the server's metadata — the
337
488
  * `/meta` response, the validator built from it and the loaded action
338
489
  * forms — so the next call fetches them again. Call it when the viewer's
@@ -340,10 +491,15 @@ var Client = class {
340
491
  * user, and a client reused across identities would otherwise keep serving
341
492
  * the previous user's columns, actions and write rules.
342
493
  *
494
+ * The next `meta()` still revalidates through the meta store, so an
495
+ * unchanged `/meta` costs a `304`, not a download. On an identity change
496
+ * also clear the store (`clearMetaStore()` / `metaStore.clear()`).
497
+ *
343
498
  * @since 0.1.151
344
499
  */
345
500
  invalidateMeta() {
346
501
  this._metaPromise = void 0;
502
+ this._metaEtag = void 0;
347
503
  this._validatorPromise = void 0;
348
504
  this._formCache.clear();
349
505
  }
@@ -599,6 +755,83 @@ var Client = class {
599
755
  if (typeof this._headers === "function") return await this._headers();
600
756
  return this._headers;
601
757
  }
758
+ /**
759
+ * `GET /meta` through the meta store: conditional when the store holds
760
+ * bodies for this key, falling back to a plain request whenever the
761
+ * conditional one cannot be answered from the store.
762
+ */
763
+ async _loadMeta() {
764
+ const url = this._endpointUrl("meta");
765
+ const store = this._metaStore;
766
+ if (!store) {
767
+ const res = await this._fetchUrl(url, {
768
+ method: "GET",
769
+ headers: await this._resolveHeaders()
770
+ });
771
+ return {
772
+ body: await this._readResponse(res, url, "GET", false),
773
+ etag: res.ok ? readEtag(res) : void 0
774
+ };
775
+ }
776
+ const key = this._metaKey ?? url;
777
+ const headers = await this._resolveHeaders();
778
+ const candidates = store._isUnconditional(key) ? [] : store.candidates(key);
779
+ if (candidates.length > 0) {
780
+ const init = {
781
+ method: "GET",
782
+ headers: {
783
+ ...headers,
784
+ "If-None-Match": candidates.join(", ")
785
+ }
786
+ };
787
+ let res;
788
+ try {
789
+ res = await this._fetchUrl(url, init);
790
+ } catch {}
791
+ if (res && res.status === 304) {
792
+ const etag = readEtag(res);
793
+ const text = etag === void 0 ? void 0 : store._reuse(key, etag);
794
+ if (text !== void 0) return {
795
+ body: JSON.parse(text),
796
+ etag
797
+ };
798
+ } else if (res) return this._storeMeta(store, key, url, res);
799
+ else {
800
+ const fallback = await this._fetchUrl(url, {
801
+ method: "GET",
802
+ headers: { ...headers }
803
+ });
804
+ store._markUnconditional(key);
805
+ return this._storeMeta(store, key, url, fallback);
806
+ }
807
+ }
808
+ const res = await this._fetchUrl(url, {
809
+ method: "GET",
810
+ headers: { ...headers }
811
+ });
812
+ return this._storeMeta(store, key, url, res);
813
+ }
814
+ /** Reads a `/meta` response; a `2xx` carrying a readable `ETag` goes into the store. */
815
+ async _storeMeta(store, key, url, res) {
816
+ const etag = res.ok ? readEtag(res) : void 0;
817
+ if (etag === void 0 || typeof res.text !== "function") return {
818
+ body: await this._readResponse(res, url, "GET", false),
819
+ etag
820
+ };
821
+ let body;
822
+ let text;
823
+ try {
824
+ text = await res.text();
825
+ body = JSON.parse(text);
826
+ } catch (cause) {
827
+ throw new TransportError("GET", url, `HTTP ${res.status} body is not JSON (${describeCause(cause)})`, cause);
828
+ }
829
+ store._put(key, etag, text);
830
+ return {
831
+ body,
832
+ etag
833
+ };
834
+ }
602
835
  _request(method, endpoint, body) {
603
836
  return this._requestUrl(method, this._endpointUrl(endpoint), body);
604
837
  }
@@ -621,13 +854,19 @@ var Client = class {
621
854
  return this._send(url, init, allowEmpty);
622
855
  }
623
856
  async _send(url, init, allowEmpty) {
624
- const method = init.method ?? "GET";
625
- let res;
857
+ const res = await this._fetchUrl(url, init);
858
+ return this._readResponse(res, url, init.method ?? "GET", allowEmpty);
859
+ }
860
+ /** `fetch` with a network failure surfaced as {@link TransportError}. */
861
+ async _fetchUrl(url, init) {
626
862
  try {
627
- res = await this._fetch(url, init);
863
+ return await this._fetch(url, init);
628
864
  } catch (cause) {
629
- throw new TransportError(method, url, describeCause(cause), cause);
865
+ throw new TransportError(init.method ?? "GET", url, describeCause(cause), cause);
630
866
  }
867
+ }
868
+ /** A response's JSON body, or the matching error for a non-2xx status. */
869
+ async _readResponse(res, url, method, allowEmpty) {
631
870
  if (!res.ok) {
632
871
  let errorBody;
633
872
  try {
@@ -656,6 +895,13 @@ var Client = class {
656
895
  }
657
896
  }
658
897
  };
898
+ /**
899
+ * The response's `ETag`, when readable — a cross-origin response hides it
900
+ * unless the server sends `Access-Control-Expose-Headers: ETag`.
901
+ */
902
+ function readEtag(res) {
903
+ return res.headers?.get?.("etag") || void 0;
904
+ }
659
905
  /** One-line rendering of a thrown value for a `TransportError` message. */
660
906
  function describeCause(cause) {
661
907
  if (cause instanceof Error) return cause.message || cause.name;
@@ -751,4 +997,4 @@ function describeShape(value) {
751
997
  return typeof value;
752
998
  }
753
999
  //#endregion
754
- export { ActionDisabledError, ActionNotFoundError, ActionTargetError, ActionUnsupportedError, Client, ClientError, TransportError, VersionMismatchError, actionIdentifier, bucketSeries, bucketStartInstant, encodeNavigateId, formatIdentifier, formatIdentifierField, nextBucketLabel };
1000
+ export { ActionDisabledError, ActionNotFoundError, ActionTargetError, ActionUnsupportedError, Client, ClientError, MetaStore, TransportError, VersionMismatchError, actionIdentifier, bucketSeries, bucketStartInstant, clearMetaStore, encodeNavigateId, formatIdentifier, formatIdentifierField, nextBucketLabel };
@@ -3,6 +3,89 @@ import { AggregateQuery as AggregateQuery$1, AggregateResult as AggregateResult$
3
3
  import { DbValidationContext, ValidatorMode, ValidatorMode as ValidatorMode$1 } from "@atscript/db/validator";
4
4
  import { DbPatch, DbResponse, DbRow, TDbDeleteResult as TDbDeleteResult$1, TDbInsertIgnoreResult as TDbInsertIgnoreResult$1, TDbInsertManyIgnoreResult as TDbInsertManyIgnoreResult$1, TDbInsertManyResult as TDbInsertManyResult$1, TDbInsertResult as TDbInsertResult$1, TDbUpdateResult as TDbUpdateResult$1, TFieldMeta, TMetaResponse, TRelationInfo, TSearchIndexInfo } from "@atscript/db";
5
5
 
6
+ //#region src/meta-store.d.ts
7
+ /** Options for {@link MetaStore}. */
8
+ interface MetaStoreOptions {
9
+ /**
10
+ * How many `/meta` bodies the store keeps (least recently used are dropped
11
+ * first). Default `50`.
12
+ */
13
+ maxEntries?: number;
14
+ /**
15
+ * After a conditional `/meta` request fails at the transport level (e.g. a
16
+ * CORS preflight refusing `If-None-Match`) while the plain retry succeeds,
17
+ * how long (ms) that store key is loaded without `If-None-Match` before a
18
+ * conditional request is tried again. Default `300000` (5 minutes).
19
+ */
20
+ unconditionalMs?: number;
21
+ }
22
+ /**
23
+ * Content-addressed cache of `GET /meta` responses, keyed by the server's
24
+ * `ETag`, used by {@link Client.meta} to revalidate instead of downloading.
25
+ *
26
+ * For each store key (the meta URL, or `ClientOptions.metaKey`) the store
27
+ * remembers the ETags it has seen. `meta()` sends them as `If-None-Match`;
28
+ * when the server answers `304 Not Modified` the client reuses the body
29
+ * stored under the returned `ETag`. Several keys that serve the same bytes
30
+ * — e.g. one parametric controller mounted at `/api/tenant/:id/todos` —
31
+ * therefore share one stored body, and the first visit to a new key costs a
32
+ * `304` instead of the full `/meta`.
33
+ *
34
+ * It is a pure cache: the server decides on every request whether the body
35
+ * is still valid. Failures are never stored.
36
+ *
37
+ * @since 0.1.153
38
+ */
39
+ declare class MetaStore {
40
+ private readonly _maxEntries;
41
+ private readonly _unconditionalMs;
42
+ /** Raw JSON body by opaque ETag (`W/` stripped — `If-None-Match` compares weakly). */
43
+ private readonly _bodies;
44
+ private readonly _keys;
45
+ constructor(options?: MetaStoreOptions);
46
+ /** Number of stored `/meta` bodies. */
47
+ get size(): number;
48
+ /**
49
+ * The ETags this store would send as `If-None-Match` for `key` — those seen
50
+ * for it whose body is still stored, most recent first.
51
+ */
52
+ candidates(key: string): string[];
53
+ /** Drops every stored body and remembered ETag. */
54
+ clear(): void;
55
+ /**
56
+ * Stored body for `etag` (weak comparison), marking it — and `etag` as the
57
+ * latest candidate for `key` — as recently used.
58
+ *
59
+ * @internal used by `Client`
60
+ */
61
+ _reuse(key: string, etag: string): string | undefined;
62
+ /**
63
+ * Stores a `200` body under its ETag and remembers the ETag for `key`.
64
+ *
65
+ * @internal used by `Client`
66
+ */
67
+ _put(key: string, etag: string, text: string): void;
68
+ /**
69
+ * Whether `key` is in its no-`If-None-Match` window; an expired window is
70
+ * cleared, so the next request is conditional again.
71
+ *
72
+ * @internal used by `Client`
73
+ */
74
+ _isUnconditional(key: string): boolean;
75
+ /** @internal used by `Client` */
76
+ _markUnconditional(key: string): void;
77
+ private _remember;
78
+ /** The entry for `key`, created if needed and moved to most recently used. */
79
+ private _touchKey;
80
+ }
81
+ /**
82
+ * Clears the shared default {@link MetaStore}. Call it on sign-out or any
83
+ * identity change (together with `Client.invalidateMeta()` on live clients).
84
+ *
85
+ * @since 0.1.153
86
+ */
87
+ declare function clearMetaStore(): void;
88
+ //#endregion
6
89
  //#region src/types.d.ts
7
90
  /**
8
91
  * Write payload for `insert()` / `update()`: every key optional, optional
@@ -48,6 +131,23 @@ interface ClientOptions {
48
131
  * preflight catches typos.
49
132
  */
50
133
  lenientWrites?: boolean;
134
+ /**
135
+ * Where `meta()` keeps revalidatable `/meta` bodies (since 0.1.153).
136
+ * Defaults to one store shared by every client in the page, so clients for
137
+ * the same controller — or for one parametric controller under different
138
+ * route params — revalidate with `If-None-Match` and reuse the body on a
139
+ * `304`. Pass your own `new MetaStore()` to isolate a group of clients, or
140
+ * `false` to always download `/meta`.
141
+ */
142
+ metaStore?: MetaStore | false;
143
+ /**
144
+ * Key under which `metaStore` remembers this client's ETags (since
145
+ * 0.1.153). Defaults to the `/meta` URL. Give clients of a parametric
146
+ * mount one shared key (e.g. the route template
147
+ * `"/api/tenant/:tenantId/todos"`) so a new route param revalidates against
148
+ * the body already loaded for another one.
149
+ */
150
+ metaKey?: string;
51
151
  }
52
152
  /** Search index metadata from the server. */
53
153
  type SearchIndexInfo = TSearchIndexInfo;
@@ -248,4 +348,4 @@ declare class ClientValidationError extends Error {
248
348
  */
249
349
  declare function createClientValidator(meta: MetaResponse, opts?: ClientValidatorOptions): ClientValidator;
250
350
  //#endregion
251
- export { SearchIndexInfo as A, Uniquery$1 as B, MetaResponse as C, PatchOf as D, PageResult as E, TDbInsertManyResult$1 as F, ValidGroupBy$1 as H, TDbInsertResult$1 as I, TDbQueryTarget as L, TDbDeleteResult$1 as M, TDbInsertIgnoreResult$1 as N, RelationInfo as O, TDbInsertManyIgnoreResult$1 as P, TDbUpdateResult$1 as R, IdOf as S, OwnOf as T, WeekStart as U, UniqueryControls as V, DbRow as _, ValidatorMode$1 as a, FieldMeta as b, AggregateResult$1 as c, BucketUnit as d, CalendarBucketLabel as f, DbPatch as g, DataOf as h, DbValidationContext as i, ServerError as j, RowOf as k, AtscriptClientShape as l, ClientResponse as m, ClientValidator as n, createClientValidator as o, ClientOptions as p, ClientValidatorOptions as r, AggregateQuery$1 as s, ClientValidationError as t, BucketExpr$1 as u, DecoratedControls as v, NavOf as w, FilterExpr as x, DecoratedQuery as y, TypedWithRelation as z };
351
+ export { SearchIndexInfo as A, Uniquery$1 as B, MetaResponse as C, PatchOf as D, PageResult as E, TDbInsertManyResult$1 as F, MetaStoreOptions as G, ValidGroupBy$1 as H, TDbInsertResult$1 as I, clearMetaStore as K, TDbQueryTarget as L, TDbDeleteResult$1 as M, TDbInsertIgnoreResult$1 as N, RelationInfo as O, TDbInsertManyIgnoreResult$1 as P, TDbUpdateResult$1 as R, IdOf as S, OwnOf as T, WeekStart as U, UniqueryControls as V, MetaStore as W, DbRow as _, ValidatorMode$1 as a, FieldMeta as b, AggregateResult$1 as c, BucketUnit as d, CalendarBucketLabel as f, DbPatch as g, DataOf as h, DbValidationContext as i, ServerError as j, RowOf as k, AtscriptClientShape as l, ClientResponse as m, ClientValidator as n, createClientValidator as o, ClientOptions as p, ClientValidatorOptions as r, AggregateQuery$1 as s, ClientValidationError as t, BucketExpr$1 as u, DecoratedControls as v, NavOf as w, FilterExpr as x, DecoratedQuery as y, TypedWithRelation as z };
@@ -3,6 +3,89 @@ import { TAtscriptAnnotatedType, TAtscriptTypeObject } from "@atscript/typescrip
3
3
  import { DbPatch, DbResponse, DbRow, TDbDeleteResult as TDbDeleteResult$1, TDbInsertIgnoreResult as TDbInsertIgnoreResult$1, TDbInsertManyIgnoreResult as TDbInsertManyIgnoreResult$1, TDbInsertManyResult as TDbInsertManyResult$1, TDbInsertResult as TDbInsertResult$1, TDbUpdateResult as TDbUpdateResult$1, TFieldMeta, TMetaResponse, TRelationInfo, TSearchIndexInfo } from "@atscript/db";
4
4
  import { DbValidationContext, ValidatorMode, ValidatorMode as ValidatorMode$1 } from "@atscript/db/validator";
5
5
 
6
+ //#region src/meta-store.d.ts
7
+ /** Options for {@link MetaStore}. */
8
+ interface MetaStoreOptions {
9
+ /**
10
+ * How many `/meta` bodies the store keeps (least recently used are dropped
11
+ * first). Default `50`.
12
+ */
13
+ maxEntries?: number;
14
+ /**
15
+ * After a conditional `/meta` request fails at the transport level (e.g. a
16
+ * CORS preflight refusing `If-None-Match`) while the plain retry succeeds,
17
+ * how long (ms) that store key is loaded without `If-None-Match` before a
18
+ * conditional request is tried again. Default `300000` (5 minutes).
19
+ */
20
+ unconditionalMs?: number;
21
+ }
22
+ /**
23
+ * Content-addressed cache of `GET /meta` responses, keyed by the server's
24
+ * `ETag`, used by {@link Client.meta} to revalidate instead of downloading.
25
+ *
26
+ * For each store key (the meta URL, or `ClientOptions.metaKey`) the store
27
+ * remembers the ETags it has seen. `meta()` sends them as `If-None-Match`;
28
+ * when the server answers `304 Not Modified` the client reuses the body
29
+ * stored under the returned `ETag`. Several keys that serve the same bytes
30
+ * — e.g. one parametric controller mounted at `/api/tenant/:id/todos` —
31
+ * therefore share one stored body, and the first visit to a new key costs a
32
+ * `304` instead of the full `/meta`.
33
+ *
34
+ * It is a pure cache: the server decides on every request whether the body
35
+ * is still valid. Failures are never stored.
36
+ *
37
+ * @since 0.1.153
38
+ */
39
+ declare class MetaStore {
40
+ private readonly _maxEntries;
41
+ private readonly _unconditionalMs;
42
+ /** Raw JSON body by opaque ETag (`W/` stripped — `If-None-Match` compares weakly). */
43
+ private readonly _bodies;
44
+ private readonly _keys;
45
+ constructor(options?: MetaStoreOptions);
46
+ /** Number of stored `/meta` bodies. */
47
+ get size(): number;
48
+ /**
49
+ * The ETags this store would send as `If-None-Match` for `key` — those seen
50
+ * for it whose body is still stored, most recent first.
51
+ */
52
+ candidates(key: string): string[];
53
+ /** Drops every stored body and remembered ETag. */
54
+ clear(): void;
55
+ /**
56
+ * Stored body for `etag` (weak comparison), marking it — and `etag` as the
57
+ * latest candidate for `key` — as recently used.
58
+ *
59
+ * @internal used by `Client`
60
+ */
61
+ _reuse(key: string, etag: string): string | undefined;
62
+ /**
63
+ * Stores a `200` body under its ETag and remembers the ETag for `key`.
64
+ *
65
+ * @internal used by `Client`
66
+ */
67
+ _put(key: string, etag: string, text: string): void;
68
+ /**
69
+ * Whether `key` is in its no-`If-None-Match` window; an expired window is
70
+ * cleared, so the next request is conditional again.
71
+ *
72
+ * @internal used by `Client`
73
+ */
74
+ _isUnconditional(key: string): boolean;
75
+ /** @internal used by `Client` */
76
+ _markUnconditional(key: string): void;
77
+ private _remember;
78
+ /** The entry for `key`, created if needed and moved to most recently used. */
79
+ private _touchKey;
80
+ }
81
+ /**
82
+ * Clears the shared default {@link MetaStore}. Call it on sign-out or any
83
+ * identity change (together with `Client.invalidateMeta()` on live clients).
84
+ *
85
+ * @since 0.1.153
86
+ */
87
+ declare function clearMetaStore(): void;
88
+ //#endregion
6
89
  //#region src/types.d.ts
7
90
  /**
8
91
  * Write payload for `insert()` / `update()`: every key optional, optional
@@ -48,6 +131,23 @@ interface ClientOptions {
48
131
  * preflight catches typos.
49
132
  */
50
133
  lenientWrites?: boolean;
134
+ /**
135
+ * Where `meta()` keeps revalidatable `/meta` bodies (since 0.1.153).
136
+ * Defaults to one store shared by every client in the page, so clients for
137
+ * the same controller — or for one parametric controller under different
138
+ * route params — revalidate with `If-None-Match` and reuse the body on a
139
+ * `304`. Pass your own `new MetaStore()` to isolate a group of clients, or
140
+ * `false` to always download `/meta`.
141
+ */
142
+ metaStore?: MetaStore | false;
143
+ /**
144
+ * Key under which `metaStore` remembers this client's ETags (since
145
+ * 0.1.153). Defaults to the `/meta` URL. Give clients of a parametric
146
+ * mount one shared key (e.g. the route template
147
+ * `"/api/tenant/:tenantId/todos"`) so a new route param revalidates against
148
+ * the body already loaded for another one.
149
+ */
150
+ metaKey?: string;
51
151
  }
52
152
  /** Search index metadata from the server. */
53
153
  type SearchIndexInfo = TSearchIndexInfo;
@@ -248,4 +348,4 @@ declare class ClientValidationError extends Error {
248
348
  */
249
349
  declare function createClientValidator(meta: MetaResponse, opts?: ClientValidatorOptions): ClientValidator;
250
350
  //#endregion
251
- export { SearchIndexInfo as A, Uniquery$1 as B, MetaResponse as C, PatchOf as D, PageResult as E, TDbInsertManyResult$1 as F, ValidGroupBy$1 as H, TDbInsertResult$1 as I, TDbQueryTarget as L, TDbDeleteResult$1 as M, TDbInsertIgnoreResult$1 as N, RelationInfo as O, TDbInsertManyIgnoreResult$1 as P, TDbUpdateResult$1 as R, IdOf as S, OwnOf as T, WeekStart as U, UniqueryControls as V, DbRow as _, ValidatorMode$1 as a, FieldMeta as b, AggregateResult$1 as c, BucketUnit as d, CalendarBucketLabel as f, DbPatch as g, DataOf as h, DbValidationContext as i, ServerError as j, RowOf as k, AtscriptClientShape as l, ClientResponse as m, ClientValidator as n, createClientValidator as o, ClientOptions as p, ClientValidatorOptions as r, AggregateQuery$1 as s, ClientValidationError as t, BucketExpr$1 as u, DecoratedControls as v, NavOf as w, FilterExpr as x, DecoratedQuery as y, TypedWithRelation as z };
351
+ export { SearchIndexInfo as A, Uniquery$1 as B, MetaResponse as C, PatchOf as D, PageResult as E, TDbInsertManyResult$1 as F, MetaStoreOptions as G, ValidGroupBy$1 as H, TDbInsertResult$1 as I, clearMetaStore as K, TDbQueryTarget as L, TDbDeleteResult$1 as M, TDbInsertIgnoreResult$1 as N, RelationInfo as O, TDbInsertManyIgnoreResult$1 as P, TDbUpdateResult$1 as R, IdOf as S, OwnOf as T, WeekStart as U, UniqueryControls as V, MetaStore as W, DbRow as _, ValidatorMode$1 as a, FieldMeta as b, AggregateResult$1 as c, BucketUnit as d, CalendarBucketLabel as f, DbPatch as g, DataOf as h, DbValidationContext as i, ServerError as j, RowOf as k, AtscriptClientShape as l, ClientResponse as m, ClientValidator as n, createClientValidator as o, ClientOptions as p, ClientValidatorOptions as r, AggregateQuery$1 as s, ClientValidationError as t, BucketExpr$1 as u, DecoratedControls as v, NavOf as w, FilterExpr as x, DecoratedQuery as y, TypedWithRelation as z };
@@ -1,2 +1,2 @@
1
- import { a as ValidatorMode, i as DbValidationContext, n as ClientValidator, o as createClientValidator, r as ClientValidatorOptions, t as ClientValidationError } from "./validator-CVM7eNP7.cjs";
1
+ import { a as ValidatorMode, i as DbValidationContext, n as ClientValidator, o as createClientValidator, r as ClientValidatorOptions, t as ClientValidationError } from "./validator-u-SCH8c3.cjs";
2
2
  export { ClientValidationError, ClientValidator, ClientValidatorOptions, type DbValidationContext, type ValidatorMode, createClientValidator };
@@ -1,2 +1,2 @@
1
- import { a as ValidatorMode, i as DbValidationContext, n as ClientValidator, o as createClientValidator, r as ClientValidatorOptions, t as ClientValidationError } from "./validator-CPmmXTAt.mjs";
1
+ import { a as ValidatorMode, i as DbValidationContext, n as ClientValidator, o as createClientValidator, r as ClientValidatorOptions, t as ClientValidationError } from "./validator-DRW4WDEX.mjs";
2
2
  export { ClientValidationError, ClientValidator, ClientValidatorOptions, type DbValidationContext, type ValidatorMode, createClientValidator };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-client",
3
- "version": "0.1.151",
3
+ "version": "0.1.153",
4
4
  "description": "Browser-compatible HTTP client for @atscript/moost-db REST endpoints.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -43,18 +43,18 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@uniqu/core": "^0.1.14",
47
- "@uniqu/url": "^0.1.14"
46
+ "@uniqu/core": "^0.1.15",
47
+ "@uniqu/url": "^0.1.15"
48
48
  },
49
49
  "devDependencies": {
50
- "@atscript/core": "^0.1.102",
51
- "@atscript/typescript": "^0.1.102",
52
- "unplugin-atscript": "^0.1.102",
53
- "@atscript/db": "0.1.151"
50
+ "@atscript/core": "^0.1.103",
51
+ "@atscript/typescript": "^0.1.103",
52
+ "unplugin-atscript": "^0.1.103",
53
+ "@atscript/db": "0.1.153"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@atscript/db": "^0.1.44",
57
- "@atscript/typescript": "^0.1.102"
57
+ "@atscript/typescript": "^0.1.103"
58
58
  },
59
59
  "scripts": {
60
60
  "postinstall": "asc -f dts",