@isaijs/core 0.1.3 → 0.1.5

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.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { G as ValidationError, H as validate, V as toKind, h as line, o as fileTypeNames, p as email, z as object } from "./validation-Cppmd0RV.js";
2
- import { B as NeedsDatabase, C as database, D as planInsert, E as recordAction, F as systemClock, J as UniqueFailed, K as NotFound, L as PLATFORM, O as planUpdate, R as TENANT, S as clockOf, U as modelOfRow, V as evaluate, j as conditionOf, k as keyCondition, r as sync, t as migrate, w as decodeStored, z as checkTenancy } from "./migrator-BU3_PHk8.js";
3
- import { C as tooLarge, E as fail, S as tenantSuspended, T as Rejected, _ as json, b as routeNotFound, c as fromBody, d as route, f as HttpError, g as fileTypeRefused, h as fileTooLarge, l as readInput, m as errorResponse, o as parseListQuery, p as crossSiteRequest, r as resource, s as BODY_LIMIT, t as Resource, u as Route, v as lengthRequired, w as tooManyRequests, x as signInRequired, y as methodNotAllowed } from "./resource-CsYksjKc.js";
4
- import { F as sensitivity, G as ulid, K as Scope, V as asActor, a as columnName, b as asCondition$1, c as fieldOf, f as Condition, m as Expr, n as toBase64url, r as Model, t as fromBase64url, u as rowOf } from "./base64url-C5GTYIL5.js";
5
- import { t as kebabCase } from "./case-BnpcvhdW.js";
6
- import { t as PLATFORM_PREFIX } from "./tables-IrLjsomf.js";
7
- import { t as Seed } from "./seed-BObmdKdP.js";
2
+ import { A as planUpdate, B as NeedsDatabase, C as database, D as recordAction, J as NotFound, L as PLATFORM, N as conditionOf, O as withTableTotals, P as modelOfRow, R as TENANT, S as clockOf, T as driverOf, U as systemClock, V as evaluate, X as UniqueFailed, j as keyCondition, k as planInsert, r as sync, t as migrate, w as decodeStored, z as checkTenancy } from "./migrator-B25prc9n.js";
3
+ import { C as tooLarge, E as fail, S as tenantSuspended, T as Rejected, _ as json, b as routeNotFound, c as fromBody, d as route, f as HttpError, g as fileTypeRefused, h as fileTooLarge, l as readInput, m as errorResponse, o as parseListQuery, p as crossSiteRequest, r as resource, s as BODY_LIMIT, t as Resource, u as Route, v as lengthRequired, w as tooManyRequests, x as signInRequired, y as methodNotAllowed } from "./resource-BQK7bkPQ.js";
4
+ import { C as asCondition$1, I as fromBase64url, L as toBase64url, U as asActor, _ as Expr, c as rowOf, h as Condition, o as fieldOf, p as sensitivity, q as Scope, r as columnName, t as Model } from "./model-Dc3HunKb.js";
5
+ import { r as ulid, t as kebabCase } from "./case-DpXlJCL9.js";
8
6
  import { n as notImplemented, t as NotImplementedError } from "./errors-mCNLlXg9.js";
9
- import { d as afterUpdate, f as bindFor, h as ownerFieldOf, m as isFalse, p as check, u as FALSE } from "./policies-CKIdBFKt.js";
10
- import { i as checkRoles, n as authPerms, r as checkAuth } from "./create-auth-Ci0PaBoX.js";
11
- import { _ as permissionsOf, a as hashOf, b as syncRoles, c as GoogleError, f as verifyIdToken, g as safeReturn, h as redirect, i as clearedCookie, l as consentUrl, m as cookieOf, n as authModels, o as sessions, p as cookie, t as makers, u as exchangeCode, v as roleKeys, y as seedAs } from "./makers-DeSNM0Nd.js";
7
+ import { t as PLATFORM_PREFIX } from "./tables-DgSwge7s.js";
8
+ import { t as Seed } from "./seed-BObmdKdP.js";
9
+ import { d as afterUpdate, f as bindFor, h as ownerFieldOf, m as isFalse, p as check, u as FALSE } from "./policies-KsqmlX67.js";
10
+ import { i as checkRoles, n as authPerms, r as checkAuth } from "./create-auth-BHdX68Ng.js";
11
+ import { _ as permissionsOf, a as hashOf, b as syncRoles, c as GoogleError, f as verifyIdToken, g as safeReturn, h as redirect, i as clearedCookie, l as consentUrl, m as cookieOf, n as authModels, o as sessions, p as cookie, t as makers, u as exchangeCode, v as roleKeys, y as seedAs } from "./makers-P9kg68C8.js";
12
12
  import { t as WrongSize } from "./storage-body-7dWyMszh.js";
13
13
  import { RegExpRouter } from "hono/router/reg-exp-router";
14
14
  import { SmartRouter } from "hono/router/smart-router";
@@ -59,6 +59,99 @@ var Router = class {
59
59
  }
60
60
  };
61
61
  //#endregion
62
+ //#region src/api/idempotency.ts
63
+ /**
64
+ * Retries made safe: a write with an `Idempotency-Key` header runs once, and the same key again
65
+ * gets the first answer back (`Idempotent-Replayed: true`) without the handler running. The
66
+ * generated client sends a key with every write and retries a write the network lost with the
67
+ * same one (`client/client.ts`), so a phone whose connection drops never makes a write twice.
68
+ *
69
+ * the same key again… answer
70
+ * same route, user and body the first answer, replayed
71
+ * while the first is still running 409 InProgress (Retry-After: 1)
72
+ * another route, user or body 422 IdempotencyKeyReused
73
+ * after 24 h a new request
74
+ *
75
+ * Every write honours a key: there's no switch per endpoint. Keys live in the `_isai_idempotency`
76
+ * table of the database the request runs against (a tenant's own, with isolated tenancy); expired
77
+ * ones are deleted as new ones are written. What's kept is the pipeline's own answers (JSON, or
78
+ * 204), errors from 400 to 499 too; a 5xx, an answer that sets a cookie, or a handler's own
79
+ * stream isn't kept, so its retry runs again. A run whose answer was never kept (the isolate died
80
+ * halfway) is taken over by a retry after a minute.
81
+ */
82
+ /** The header a client sends, and the one a replayed answer carries. */
83
+ const IDEMPOTENCY_KEY = "Idempotency-Key";
84
+ const REPLAYED = "Idempotent-Replayed";
85
+ /** How long a key is kept. */
86
+ const KEPT_FOR = 864e5;
87
+ /** Keys are a client's random ids (ULIDs, UUIDs): printable, and short. */
88
+ const KEY = /^[\x21-\x7e]{1,255}$/;
89
+ const TABLE = `"_isai_idempotency"`;
90
+ const inProgress = () => new HttpError(409, "InProgress", "This request is still running; try again in a moment", { "Retry-After": "1" });
91
+ const keyReused = () => new HttpError(422, "IdempotencyKeyReused", "This Idempotency-Key was used for another request");
92
+ const badKey = () => new HttpError(400, "BadIdempotencyKey", "An Idempotency-Key is 1 to 255 printable characters");
93
+ /**
94
+ * `run` once for the request's key, or the answer it gave before. `scope` is what the key must
95
+ * match besides the body: the route and the user. Without a key, `run` simply runs.
96
+ */
97
+ async function idempotent(db, request, scope, run) {
98
+ const key = request.headers.get(IDEMPOTENCY_KEY);
99
+ if (key === null) return run();
100
+ if (!KEY.test(key)) throw badKey();
101
+ const driver = driverOf(db);
102
+ const now = clockOf(db).now().getTime();
103
+ const body = await digest(request);
104
+ const query = (text, ...params) => driver.query({
105
+ text,
106
+ params
107
+ });
108
+ await query(`DELETE FROM ${TABLE} WHERE "created_at" < ?`, now - KEPT_FOR);
109
+ if ((await query(`INSERT INTO ${TABLE} ("key", "scope", "body", "created_at") VALUES (?, ?, ?, ?) ON CONFLICT ("key") DO NOTHING RETURNING "key"`, key, scope, body, now)).length === 0) {
110
+ const [kept] = await query(`SELECT "scope", "body", "status", "response", "content_type", "created_at" FROM ${TABLE} WHERE "key" = ?`, key);
111
+ if (!kept) return run();
112
+ if (kept.scope !== scope || kept.body !== body) throw keyReused();
113
+ if (kept.status !== null) return replay(kept);
114
+ if (kept.created_at > now - 6e4) throw inProgress();
115
+ if ((await query(`UPDATE ${TABLE} SET "created_at" = ? WHERE "key" = ? AND "status" IS NULL AND "created_at" = ? RETURNING "key"`, now, key, kept.created_at)).length === 0) throw inProgress();
116
+ }
117
+ let response;
118
+ try {
119
+ response = await run();
120
+ } catch (error) {
121
+ await query(`DELETE FROM ${TABLE} WHERE "key" = ?`, key);
122
+ throw error;
123
+ }
124
+ if (!keepable(response)) {
125
+ await query(`DELETE FROM ${TABLE} WHERE "key" = ?`, key);
126
+ return response;
127
+ }
128
+ const text = response.status === 204 ? null : await response.text();
129
+ const type = response.headers.get("Content-Type");
130
+ await query(`UPDATE ${TABLE} SET "status" = ?, "response" = ?, "content_type" = ? WHERE "key" = ?`, response.status, text, type, key);
131
+ return new Response(text, response);
132
+ }
133
+ /** An answer worth replaying: a client's (not a 5xx), with nothing a replay couldn't repeat. */
134
+ function keepable(response) {
135
+ if (response.status >= 500 || response.headers.has("Set-Cookie")) return false;
136
+ if (response.status === 204) return true;
137
+ return response.headers.get("Content-Type")?.startsWith("application/json") ?? false;
138
+ }
139
+ function replay(kept) {
140
+ return new Response(kept.response, {
141
+ status: kept.status ?? 200,
142
+ headers: {
143
+ ...kept.content_type ? { "Content-Type": kept.content_type } : {},
144
+ [REPLAYED]: "true"
145
+ }
146
+ });
147
+ }
148
+ /** The body's SHA-256 (hex), read from a copy so the handler still has it. */
149
+ async function digest(request) {
150
+ const bytes = request.body ? await request.clone().arrayBuffer() : /* @__PURE__ */ new ArrayBuffer(0);
151
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
152
+ return Array.from(hash, (byte) => byte.toString(16).padStart(2, "0")).join("");
153
+ }
154
+ //#endregion
62
155
  //#region src/api/output.ts
63
156
  /**
64
157
  * What a handler returns, as JSON. Rows the ORM returned are found
@@ -219,7 +312,7 @@ function createPipeline(options) {
219
312
  if (!signedIn && !SAFE_METHODS.has(request.method) && options.anonymousLimit) {
220
313
  if (!await options.anonymousLimit.allow(clientIp(request))) throw tooManyRequests();
221
314
  }
222
- return await dispatch(request, url, extra, signedIn, place);
315
+ return await dispatch(request, url, requestId, extra, signedIn, place);
223
316
  } catch (error) {
224
317
  return errorResponse(error, requestId, place);
225
318
  }
@@ -234,7 +327,7 @@ function createPipeline(options) {
234
327
  });
235
328
  }
236
329
  /** The framework's own endpoints, then the route the path matches. */
237
- async function dispatch(request, url, extra, signedIn, place) {
330
+ async function dispatch(request, url, requestId, extra, signedIn, place) {
238
331
  const own = await options.framework?.(request, url);
239
332
  if (own) return own;
240
333
  if (!url.pathname.startsWith(`/api/`) && url.pathname !== "/api") throw routeNotFound();
@@ -249,6 +342,12 @@ function createPipeline(options) {
249
342
  if (!SAFE_METHODS.has(request.method) && isCrossSite(request, url, allowedOrigin)) throw crossSiteRequest();
250
343
  const user = signedIn?.user;
251
344
  if (!user && !route.anonymous) throw signInRequired();
345
+ const run = () => answer(request, url, route, match.params, extra, user);
346
+ if (SAFE_METHODS.has(request.method) || !options.idempotency) return run();
347
+ return idempotent(options.idempotency(), request, `${place.route} ${user?.id ?? ""}`, () => run().catch((error) => errorResponse(error, requestId, place)));
348
+ }
349
+ /** A matched route's input, its handler, and its output. */
350
+ async function answer(request, url, route, params, extra, user) {
252
351
  const tenant = extra.tenant;
253
352
  const { db, can } = (options.context ?? noData)(user, extra.tenant);
254
353
  const files = route.input === "own" ? void 0 : options.files?.(route);
@@ -265,7 +364,7 @@ function createPipeline(options) {
265
364
  db,
266
365
  can,
267
366
  user,
268
- params: match.params,
367
+ params,
269
368
  input,
270
369
  request,
271
370
  tenant
@@ -353,7 +452,7 @@ function preflight(crossOrigin) {
353
452
  status: 204,
354
453
  headers: {
355
454
  "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE",
356
- "Access-Control-Allow-Headers": "Authorization, Content-Type",
455
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key",
357
456
  "Access-Control-Max-Age": "86400"
358
457
  }
359
458
  });
@@ -602,7 +701,7 @@ function boundary(options) {
602
701
  }
603
702
  case "createOne":
604
703
  case "createMany": {
605
- const node = required(await verdict(model, action));
704
+ const node = await withTableTotals(db, model.spec, required(await verdict(model, action)));
606
705
  const inputs = (plan.op === "createOne" ? [plan.args[0]] : plan.args[0]).map((input) => withOwner(model, input));
607
706
  const now = clockOf(db).now();
608
707
  for (const input of inputs) {
@@ -944,7 +1043,7 @@ function rescope(node, scope) {
944
1043
  };
945
1044
  case "aggregate": return {
946
1045
  ...o,
947
- relation: o.relation.owner.scope === 0 ? {
1046
+ relation: o.relation.owner?.scope === 0 ? {
948
1047
  ...o.relation,
949
1048
  owner: {
950
1049
  ...o.relation.owner,
@@ -971,7 +1070,7 @@ function rescope(node, scope) {
971
1070
  };
972
1071
  case "some": return {
973
1072
  ...node,
974
- relation: node.relation.owner.scope === 0 ? {
1073
+ relation: node.relation.owner?.scope === 0 ? {
975
1074
  ...node.relation,
976
1075
  owner: {
977
1076
  ...node.relation.owner,
@@ -3125,6 +3224,7 @@ function createCore(config, env, options = {}) {
3125
3224
  door,
3126
3225
  ...config.api?.rateLimit?.anonymous ? { anonymousLimit: config.api.rateLimit.anonymous } : {},
3127
3226
  context: contextOf(currentDb, models, auth?.policies),
3227
+ idempotency: currentDb,
3128
3228
  builtins: [...uploads.routes, ...models && signIns ? [
3129
3229
  ...authRoutes(models, signIns, currentDb, () => auth?.permissionKeys() ?? []),
3130
3230
  ...peopleRoutes(models, signIns, currentDb),
@@ -3196,6 +3296,7 @@ function createCore(config, env, options = {}) {
3196
3296
  ...config.api?.rateLimit?.anonymous ? { anonymousLimit: config.api.rateLimit.anonymous } : {},
3197
3297
  builtins: [...authRoutes(models, signIns, platformDb, () => auth.permissionKeys()), ...peopleRoutes(models, signIns, platformDb)],
3198
3298
  context: contextOf(platformDb, models, auth.policies),
3299
+ idempotency: platformDb,
3199
3300
  session: sessionOf(signIns),
3200
3301
  framework: firstOf([
3201
3302
  googlePlatform({
@@ -1,4 +1,4 @@
1
- import { F as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-BU3_PHk8.js";
1
+ import { U as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-B25prc9n.js";
2
2
  //#region src/db/migrations/cleanup.ts
3
3
  /** Dropped tables, those that refer to others first, so no row is left pointing at a dropped one. */
4
4
  function dropOrder(tables) {
@@ -1,6 +1,6 @@
1
1
  import { K as Fake, q as fakeValue } from "./validation-Cppmd0RV.js";
2
- import { D as planInsert, E as recordAction, S as clockOf, T as forUpkeep, W as CheckFailed } from "./migrator-BU3_PHk8.js";
3
- import { j as IMPLICIT_FIELDS, n as toBase64url, r as Model, t as fromBase64url } from "./base64url-C5GTYIL5.js";
2
+ import { D as recordAction, E as forUpkeep, K as CheckFailed, S as clockOf, k as planInsert } from "./migrator-B25prc9n.js";
3
+ import { I as fromBase64url, L as toBase64url, l as IMPLICIT_FIELDS, t as Model } from "./model-Dc3HunKb.js";
4
4
  //#region src/auth/roles.ts
5
5
  const same = (a, b) => a.length === b.length && a.every((value, i) => value === b[i]);
6
6
  /** What a role in `app.ts` is stored as. */
@@ -1,5 +1,6 @@
1
1
  import { G as ValidationError, M as id } from "./validation-Cppmd0RV.js";
2
- import { A as unbound, B as aroundUpsert, E as fieldsOf, G as ulid, H as assertNotLog, I as actionEntry, L as aroundCreate, O as readsNow, R as aroundDelete, T as storageOf, W as wrap, a as columnName, c as fieldOf, f as Condition, h as Now, i as assertNotEncrypted, j as IMPLICIT_FIELDS, k as readsOtherRows, m as Expr, n as toBase64url, o as columnNode, t as fromBase64url$1, u as rowOf$1, x as conditionNode, z as aroundUpdate } from "./base64url-C5GTYIL5.js";
2
+ import { A as mapOperands, B as aroundDelete, F as unbound, H as aroundUpsert, I as fromBase64url$1, K as wrap, L as toBase64url, M as readsNow, N as readsOtherRows, O as storageOf, P as tableTotals, R as actionEntry, V as aroundUpdate, W as assertNotLog, _ as Expr, c as rowOf$1, h as Condition, i as columnNode, k as fieldsOf, l as IMPLICIT_FIELDS, n as assertNotEncrypted, o as fieldOf, r as columnName, v as Now, w as conditionNode, z as aroundCreate } from "./model-Dc3HunKb.js";
3
+ import { r as ulid } from "./case-DpXlJCL9.js";
3
4
  import { t as NotImplementedError } from "./errors-mCNLlXg9.js";
4
5
  //#region src/db/errors.ts
5
6
  var DatabaseError = class extends Error {
@@ -60,16 +61,79 @@ var NotFound = class extends DatabaseError {
60
61
  }
61
62
  };
62
63
  //#endregion
63
- //#region src/db/orm/tag.ts
64
- const MODEL = Symbol.for("isai.model");
65
- function tagRow(row, model) {
66
- row[MODEL] = model;
67
- return row;
68
- }
69
- /** The model a value was read from, when it's a row the ORM returned (or a copy of one). */
70
- function modelOfRow(value) {
71
- return value[MODEL];
64
+ //#region src/internal/secret-box.ts
65
+ /**
66
+ * Encrypted values for `secret` fields: AES-256-GCM through Web Crypto, on every runtime.
67
+ *
68
+ * A sealed value is `v1.<key id>.<iv>.<ciphertext>` (base64url): a fresh 96-bit IV each time,
69
+ * and the key's id (from its hash) so a value sealed with another key fails clearly, and old keys
70
+ * can be kept for reading when rotation comes.
71
+ */
72
+ const VERSION = "v1";
73
+ /** A new key's text: 32 random bytes, base64url (what `secretKey` takes). */
74
+ function newSecretKey() {
75
+ return toBase64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
72
76
  }
77
+ var SecretBox = class {
78
+ ready;
79
+ /** `key`: 32 bytes, base64url (`newSecretKey()`). */
80
+ constructor(key) {
81
+ const bytes = fromBase64url$1(key);
82
+ if (bytes?.length !== 32) throw new TypeError("isai: secretKey must be 32 bytes, base64url (make one with newSecretKey())");
83
+ this.ready = (async () => {
84
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
85
+ return {
86
+ key: await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]),
87
+ id: toBase64url(hash.slice(0, 6))
88
+ };
89
+ })();
90
+ }
91
+ async seal(plaintext) {
92
+ const { key, id } = await this.ready;
93
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
94
+ const data = new TextEncoder().encode(plaintext);
95
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({
96
+ name: "AES-GCM",
97
+ iv
98
+ }, key, data));
99
+ return [
100
+ VERSION,
101
+ id,
102
+ toBase64url(iv),
103
+ toBase64url(sealed)
104
+ ].join(".");
105
+ }
106
+ /** The plaintext, or why it can't be read. */
107
+ async open(sealed) {
108
+ const { key, id } = await this.ready;
109
+ const [version, keyId, ivText, dataText, ...rest] = sealed.split(".");
110
+ const iv = fromBase64url$1(ivText ?? "");
111
+ const data = fromBase64url$1(dataText ?? "");
112
+ if (version !== VERSION || rest.length > 0 || iv?.length !== 12 || !data) return {
113
+ ok: false,
114
+ reason: "isn't an encrypted value"
115
+ };
116
+ if (keyId !== id) return {
117
+ ok: false,
118
+ reason: "was encrypted with another key"
119
+ };
120
+ try {
121
+ const plain = await crypto.subtle.decrypt({
122
+ name: "AES-GCM",
123
+ iv
124
+ }, key, data);
125
+ return {
126
+ ok: true,
127
+ value: new TextDecoder().decode(plain)
128
+ };
129
+ } catch {
130
+ return {
131
+ ok: false,
132
+ reason: "failed its integrity check (changed or corrupted)"
133
+ };
134
+ }
135
+ }
136
+ };
73
137
  //#endregion
74
138
  //#region src/internal/timezone.ts
75
139
  /**
@@ -142,6 +206,21 @@ function zone(name) {
142
206
  }
143
207
  const utc = zone("UTC");
144
208
  //#endregion
209
+ //#region src/db/clock/index.ts
210
+ const systemClock = { now: () => /* @__PURE__ */ new Date() };
211
+ function fixedClock(start) {
212
+ let current = new Date(start);
213
+ return {
214
+ now: () => new Date(current),
215
+ set: (to) => {
216
+ current = new Date(to);
217
+ },
218
+ advance: (ms) => {
219
+ current = new Date(current.getTime() + ms);
220
+ }
221
+ };
222
+ }
223
+ //#endregion
145
224
  //#region src/db/conditions/evaluate.ts
146
225
  /**
147
226
  * Interprets a condition in JS against a row that isn't stored yet (`next`, checks before
@@ -311,95 +390,6 @@ function checkTenancy(tenancy, databases) {
311
390
  if (other) throw new TypeError(`isai: with tenancy, the databases are tenant and platform; databases.${other} isn't supported yet`);
312
391
  }
313
392
  //#endregion
314
- //#region src/internal/secret-box.ts
315
- /**
316
- * Encrypted values for `secret` fields: AES-256-GCM through Web Crypto, on every runtime.
317
- *
318
- * A sealed value is `v1.<key id>.<iv>.<ciphertext>` (base64url): a fresh 96-bit IV each time,
319
- * and the key's id (from its hash) so a value sealed with another key fails clearly, and old keys
320
- * can be kept for reading when rotation comes.
321
- */
322
- const VERSION = "v1";
323
- /** A new key's text: 32 random bytes, base64url (what `secretKey` takes). */
324
- function newSecretKey() {
325
- return toBase64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
326
- }
327
- var SecretBox = class {
328
- ready;
329
- /** `key`: 32 bytes, base64url (`newSecretKey()`). */
330
- constructor(key) {
331
- const bytes = fromBase64url$1(key);
332
- if (bytes?.length !== 32) throw new TypeError("isai: secretKey must be 32 bytes, base64url (make one with newSecretKey())");
333
- this.ready = (async () => {
334
- const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
335
- return {
336
- key: await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]),
337
- id: toBase64url(hash.slice(0, 6))
338
- };
339
- })();
340
- }
341
- async seal(plaintext) {
342
- const { key, id } = await this.ready;
343
- const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
344
- const data = new TextEncoder().encode(plaintext);
345
- const sealed = new Uint8Array(await crypto.subtle.encrypt({
346
- name: "AES-GCM",
347
- iv
348
- }, key, data));
349
- return [
350
- VERSION,
351
- id,
352
- toBase64url(iv),
353
- toBase64url(sealed)
354
- ].join(".");
355
- }
356
- /** The plaintext, or why it can't be read. */
357
- async open(sealed) {
358
- const { key, id } = await this.ready;
359
- const [version, keyId, ivText, dataText, ...rest] = sealed.split(".");
360
- const iv = fromBase64url$1(ivText ?? "");
361
- const data = fromBase64url$1(dataText ?? "");
362
- if (version !== VERSION || rest.length > 0 || iv?.length !== 12 || !data) return {
363
- ok: false,
364
- reason: "isn't an encrypted value"
365
- };
366
- if (keyId !== id) return {
367
- ok: false,
368
- reason: "was encrypted with another key"
369
- };
370
- try {
371
- const plain = await crypto.subtle.decrypt({
372
- name: "AES-GCM",
373
- iv
374
- }, key, data);
375
- return {
376
- ok: true,
377
- value: new TextDecoder().decode(plain)
378
- };
379
- } catch {
380
- return {
381
- ok: false,
382
- reason: "failed its integrity check (changed or corrupted)"
383
- };
384
- }
385
- }
386
- };
387
- //#endregion
388
- //#region src/db/clock/index.ts
389
- const systemClock = { now: () => /* @__PURE__ */ new Date() };
390
- function fixedClock(start) {
391
- let current = new Date(start);
392
- return {
393
- now: () => new Date(current),
394
- set: (to) => {
395
- current = new Date(to);
396
- },
397
- advance: (ms) => {
398
- current = new Date(current.getTime() + ms);
399
- }
400
- };
401
- }
402
- //#endregion
403
393
  //#region src/db/dialects/sqlite/values.ts
404
394
  function encode(storage, value) {
405
395
  if (value === null || value === void 0) return null;
@@ -446,7 +436,7 @@ function rowOf(state, scope) {
446
436
  if (row === void 0) throw new TypeError("isai: a column is used outside the callback it belongs to");
447
437
  return row;
448
438
  }
449
- function value(encoded, state) {
439
+ function value$1(encoded, state) {
450
440
  if (state.scope.values.mode === "inline") return literal(encoded);
451
441
  state.scope.values.params.push(encoded);
452
442
  return "?";
@@ -473,6 +463,11 @@ function column$1(node, state) {
473
463
  /** The relation's rows as `FROM … WHERE <they reference the owner>`, with their scope bound to a new alias. */
474
464
  function relationSource(relation, state) {
475
465
  const owner = relation.owner;
466
+ if (!owner) {
467
+ const name = alias(state);
468
+ state.rows.set(relation.scope, name);
469
+ return `FROM ${quote(relation.table)} AS ${name} WHERE 1`;
470
+ }
476
471
  const last = owner.path.at(-1);
477
472
  const ownerRow = rowOf(state, owner.scope);
478
473
  const ownerId = last ? columnThrough(state, ownerRow, owner.path.slice(0, -1), last.column) : `${ownerRow}."id"`;
@@ -483,11 +478,11 @@ function relationSource(relation, state) {
483
478
  function operand(node, partner, state) {
484
479
  switch (node.t) {
485
480
  case "column": return column$1(node, state);
486
- case "value": return value(partner ? encode(partner, node.value) : node.value, state);
481
+ case "value": return value$1(partner ? encode(partner, node.value) : node.value, state);
487
482
  case "now": {
488
483
  if (!state.scope.now) throw new TypeError("isai: `now` can't be used here");
489
484
  const at = state.scope.now.getTime() + node.offsetMs;
490
- return value(partner === "date" ? state.scope.zone.localDate(at) : at, state);
485
+ return value$1(partner === "date" ? state.scope.zone.localDate(at) : at, state);
491
486
  }
492
487
  case "truth": return `(${compileIn(node.condition, state)})`;
493
488
  case "local": {
@@ -581,13 +576,13 @@ function compileIn(node, state) {
581
576
  if (node.values.length === 0) return "0";
582
577
  const left = operand(node.operand, void 0, state);
583
578
  const storage = storageOf(node.operand);
584
- return `${left} IN (${(isSequence(node.operand) ? node.values.map(sequenceNumber) : node.values).map((v) => value(storage ? encode(storage, v) : v, state)).join(", ")})`;
579
+ return `${left} IN (${(isSequence(node.operand) ? node.values.map(sequenceNumber) : node.values).map((v) => value$1(storage ? encode(storage, v) : v, state)).join(", ")})`;
585
580
  }
586
581
  case "text": {
587
582
  const target = operand(node.operand, void 0, state);
588
- if (node.op === "icontains") return `instr(lower(${target}), lower(${value(node.value, state)})) > 0`;
589
- if (node.op === "istartswith") return `instr(lower(${target}), lower(${value(node.value, state)})) = 1`;
590
- return `instr(${target}, ${value(node.value, state)}) ${node.op === "startsWith" ? "= 1" : "> 0"}`;
583
+ if (node.op === "icontains") return `instr(lower(${target}), lower(${value$1(node.value, state)})) > 0`;
584
+ if (node.op === "istartswith") return `instr(lower(${target}), lower(${value$1(node.value, state)})) = 1`;
585
+ return `instr(${target}, ${value$1(node.value, state)}) ${node.op === "startsWith" ? "= 1" : "> 0"}`;
591
586
  }
592
587
  case "some": return `EXISTS (SELECT 1 ${relationSource(node.relation, state)} AND ${compileIn(node.where, state)})`;
593
588
  case "and": return node.items.length === 0 ? "1" : balanced(node.items, "AND", state);
@@ -851,6 +846,12 @@ function count(model, where, context) {
851
846
  return writer.sql(`SELECT COUNT(*) AS ${quote("count")} FROM ${quote(model.table)}${writer.where(where)}`);
852
847
  });
853
848
  }
849
+ function value(model, node, context) {
850
+ return withinLimit(context, (mode) => {
851
+ const writer = new Writer(model, mode, context);
852
+ return writer.sql(`SELECT ${compileOperand(node, writer.scope)} AS ${quote("value")}`);
853
+ });
854
+ }
854
855
  function exists(model, where, context) {
855
856
  return withinLimit(context, (mode) => {
856
857
  const writer = new Writer(model, mode, context);
@@ -1287,6 +1288,7 @@ const sqlite = {
1287
1288
  totals,
1288
1289
  decodeTotals,
1289
1290
  exists,
1291
+ value,
1290
1292
  update,
1291
1293
  delete: remove,
1292
1294
  reorder,
@@ -1306,7 +1308,8 @@ const sqlite = {
1306
1308
  `CREATE TABLE IF NOT EXISTS ${MIGRATIONS} ("name" TEXT PRIMARY KEY, "kind" TEXT NOT NULL, "checksum" TEXT NOT NULL, "release" TEXT NOT NULL, "applied_at" INTEGER NOT NULL, "cursor" TEXT, "rerun" INTEGER NOT NULL DEFAULT 0)`,
1307
1309
  `CREATE TABLE IF NOT EXISTS "_isai_releases" ("id" TEXT PRIMARY KEY, "seq" INTEGER NOT NULL UNIQUE, "commit" TEXT, "worker_version" TEXT, "status" TEXT NOT NULL, "schema" TEXT NOT NULL, "by" TEXT, "reason" TEXT, "started_at" INTEGER NOT NULL, "live_at" INTEGER, "rolled_back_at" INTEGER)`,
1308
1310
  `CREATE TABLE IF NOT EXISTS "_isai_lock" ("id" INTEGER PRIMARY KEY CHECK ("id" = 1), "holder" TEXT, "command" TEXT, "since" INTEGER, "expires" INTEGER, "hold_release" TEXT, "hold_reason" TEXT)`,
1309
- `CREATE TABLE IF NOT EXISTS ${SEQUENCES} ("name" TEXT PRIMARY KEY, "value" INTEGER NOT NULL)`
1311
+ `CREATE TABLE IF NOT EXISTS ${SEQUENCES} ("name" TEXT PRIMARY KEY, "value" INTEGER NOT NULL)`,
1312
+ `CREATE TABLE IF NOT EXISTS "_isai_idempotency" ("key" TEXT PRIMARY KEY, "scope" TEXT NOT NULL, "body" TEXT NOT NULL, "status" INTEGER, "response" TEXT, "content_type" TEXT, "created_at" INTEGER NOT NULL)`
1310
1313
  ],
1311
1314
  rules: rulesOf,
1312
1315
  refill,
@@ -1327,6 +1330,17 @@ function dialectFor(name) {
1327
1330
  throw new NotImplementedError(`the ${name} dialect`);
1328
1331
  }
1329
1332
  //#endregion
1333
+ //#region src/db/orm/tag.ts
1334
+ const MODEL = Symbol.for("isai.model");
1335
+ function tagRow(row, model) {
1336
+ row[MODEL] = model;
1337
+ return row;
1338
+ }
1339
+ /** The model a value was read from, when it's a row the ORM returned (or a copy of one). */
1340
+ function modelOfRow(value) {
1341
+ return value[MODEL];
1342
+ }
1343
+ //#endregion
1330
1344
  //#region src/db/orm/translate.ts
1331
1345
  /** The field stored in `column`. */
1332
1346
  function fieldAt(model, column) {
@@ -2395,6 +2409,8 @@ var Transaction = class {
2395
2409
  };
2396
2410
  const UPKEEP = Symbol("isai:upkeep");
2397
2411
  const CLOCK = Symbol("isai:clock");
2412
+ const DRIVER = Symbol("isai:driver");
2413
+ const TOTALS = Symbol("isai:totals");
2398
2414
  const RECORD = Symbol("isai:record");
2399
2415
  const DECODE = Symbol("isai:decode");
2400
2416
  var Database = class Database {
@@ -2419,6 +2435,23 @@ var Database = class Database {
2419
2435
  [CLOCK]() {
2420
2436
  return this.engine.clock;
2421
2437
  }
2438
+ /** See `withTableTotals`. */
2439
+ async [TOTALS](model, node) {
2440
+ const values = /* @__PURE__ */ new Map();
2441
+ for (const total of tableTotals(node)) {
2442
+ const [row] = await this.engine.run(this.engine.dialect.value(model, total, this.engine.context()), [model]);
2443
+ values.set(total, Number(row?.value ?? 0));
2444
+ }
2445
+ if (values.size === 0) return node;
2446
+ return mapOperands(node, (o) => values.has(o) ? {
2447
+ t: "value",
2448
+ value: values.get(o)
2449
+ } : o);
2450
+ }
2451
+ /** See `driverOf`. */
2452
+ [DRIVER]() {
2453
+ return this.engine.driver;
2454
+ }
2422
2455
  /**
2423
2456
  * The post-commit write stream: `listener` is called after every committed write with what it
2424
2457
  * created, changed and deleted (an `atomic`'s writes in one call). Returns the unsubscribe.
@@ -2591,6 +2624,17 @@ function decodeStored(db, model, row) {
2591
2624
  function clockOf(db) {
2592
2625
  return db[CLOCK]();
2593
2626
  }
2627
+ /**
2628
+ * The rule's totals over whole tables (`rows(Product).count()`), each run as a query and replaced
2629
+ * by its value, so the rule can be checked against a row in JS (a create's new row). Internal.
2630
+ */
2631
+ async function withTableTotals(db, model, node) {
2632
+ return db[TOTALS](model, node);
2633
+ }
2634
+ /** The driver under a database, for the framework's own `_isai_*` tables (idempotency keys). */
2635
+ function driverOf(db) {
2636
+ return db[DRIVER]();
2637
+ }
2594
2638
  function database(options) {
2595
2639
  return new Database(options);
2596
2640
  }
@@ -3561,4 +3605,4 @@ async function migrateFresh(driver, models, options = {}) {
3561
3605
  await sync(driver, manifest, options);
3562
3606
  }
3563
3607
  //#endregion
3564
- export { Query as A, NeedsDatabase as B, database as C, planInsert as D, recordAction as E, systemClock as F, DatabaseError as G, utc as H, newSecretKey as I, UniqueFailed as J, NotFound as K, PLATFORM as L, dialectFor as M, sqlite as N, planUpdate as O, fixedClock as P, TENANT as R, clockOf as S, forUpkeep as T, modelOfRow as U, evaluate as V, CheckFailed as W, TODO as _, currentRelease as a, Database as b, rollback as c, applyRules as d, dayOf as f, UnsafeMigration as g, snapshotOf as h, acquireLock as i, conditionOf as j, keyCondition as k, setReleaseStatus as l, emptySnapshot as m, migrateFresh as n, holdOf as o, generateMigration as p, ReferenceFailed as q, sync as r, releaseLock as s, migrate as t, startRelease as u, dataStep as v, decodeStored as w, Transaction as x, MigrationError as y, checkTenancy as z };
3608
+ export { planUpdate as A, NeedsDatabase as B, database as C, recordAction as D, forUpkeep as E, dialectFor as F, newSecretKey as G, fixedClock as H, sqlite as I, NotFound as J, CheckFailed as K, PLATFORM as L, Query as M, conditionOf as N, withTableTotals as O, modelOfRow as P, TENANT as R, clockOf as S, driverOf as T, systemClock as U, evaluate as V, utc as W, UniqueFailed as X, ReferenceFailed as Y, TODO as _, currentRelease as a, Database as b, rollback as c, applyRules as d, dayOf as f, UnsafeMigration as g, snapshotOf as h, acquireLock as i, keyCondition as j, planInsert as k, setReleaseStatus as l, emptySnapshot as m, migrateFresh as n, holdOf as o, generateMigration as p, DatabaseError as q, sync as r, releaseLock as s, migrate as t, startRelease as u, dataStep as v, decodeStored as w, Transaction as x, MigrationError as y, checkTenancy as z };