@isaijs/core 0.1.0 → 0.1.3

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.
Files changed (45) hide show
  1. package/dist/adapters/cache/cloudflare-kv/index.d.ts +1 -1
  2. package/dist/adapters/database/d1/index.d.ts +1 -1
  3. package/dist/adapters/database/durable-object/index.d.ts +1 -1
  4. package/dist/adapters/database/sqlite-node/index.d.ts +1 -1
  5. package/dist/adapters/mail/gmail/index.d.ts +1 -1
  6. package/dist/adapters/pdf/cloudflare-browser/index.d.ts +1 -1
  7. package/dist/adapters/queue/cloudflare/index.d.ts +1 -1
  8. package/dist/adapters/ratelimit/cloudflare/index.d.ts +1 -1
  9. package/dist/adapters/ratelimit/memory/index.d.ts +1 -1
  10. package/dist/adapters/runtime/cloudflare/index.d.ts +1 -1
  11. package/dist/adapters/runtime/cloudflare/index.js +1 -1
  12. package/dist/adapters/storage/disk/index.d.ts +1 -1
  13. package/dist/adapters/storage/memory/index.d.ts +1 -1
  14. package/dist/adapters/storage/r2/index.d.ts +1 -1
  15. package/dist/auth/index.d.ts +2 -2
  16. package/dist/auth/index.js +2 -2
  17. package/dist/{base64url-DwWPqoOT.js → base64url-C5GTYIL5.js} +23 -9
  18. package/dist/cli/index.js +18 -15
  19. package/dist/client/index.d.ts +46 -5
  20. package/dist/client/index.js +156 -8
  21. package/dist/{create-auth-Byf1-VJw.js → create-auth-Ci0PaBoX.js} +5 -5
  22. package/dist/{data-layer-BMviRJaW.d.ts → data-layer-D1BFo3wO.d.ts} +29 -12
  23. package/dist/{data-layer-BNtz40PV.js → data-layer-_TZtuy-F.js} +19 -11
  24. package/dist/db/index.d.ts +3 -3
  25. package/dist/db/index.js +5 -5
  26. package/dist/{index-BVCrbMav.d.ts → index-CYcklQ1l.d.ts} +21 -7
  27. package/dist/{index-BKwTOLop.d.ts → index-Ce5p2-EB.d.ts} +2 -2
  28. package/dist/{index-CBWFoL3s.d.ts → index-DlgVg3D8.d.ts} +38 -26
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +152 -56
  31. package/dist/{issues-Bxq4JExX.d.ts → issues-CKto95IW.d.ts} +3 -1
  32. package/dist/{make-DKzPd2b_.js → make-DKBn79cH.js} +17 -2
  33. package/dist/{config-D6PexpSC.js → makers-DeSNM0Nd.js} +13 -39
  34. package/dist/{migrator-C65RM_K-.js → migrator-BU3_PHk8.js} +102 -10
  35. package/dist/{policies-yVzFCjxU.js → policies-CKIdBFKt.js} +3 -3
  36. package/dist/ports/index.d.ts +1 -1
  37. package/dist/{resource-9fjxJgmz.js → resource-CsYksjKc.js} +42 -15
  38. package/dist/{tables-C3Uza4qK.js → tables-IrLjsomf.js} +4 -4
  39. package/dist/testing/index.d.ts +2 -2
  40. package/dist/testing/index.js +6 -6
  41. package/dist/{types-BFc_NEx8.d.ts → types-S6zF5IXV.d.ts} +15 -12
  42. package/dist/validation/index.d.ts +3 -3
  43. package/dist/validation/index.js +1 -1
  44. package/dist/{validation-l5JG-PFn.js → validation-Cppmd0RV.js} +23 -7
  45. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
- import { B as toKind, R as object, V as validate, W as ValidationError, a as fileTypeNames, f as email, m as line } from "./validation-l5JG-PFn.js";
2
- import { B as modelOfRow, C as database, D as planInsert, E as recordAction, F as systemClock, G as UniqueFailed, L as NeedsDatabase, O as planUpdate, R as evaluate, S as clockOf, U as NotFound, j as conditionOf, k as keyCondition, r as sync, t as migrate, w as decodeStored } from "./migrator-C65RM_K-.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-9fjxJgmz.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-DwWPqoOT.js";
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
5
  import { t as kebabCase } from "./case-BnpcvhdW.js";
6
- import { t as PLATFORM_PREFIX } from "./tables-C3Uza4qK.js";
6
+ import { t as PLATFORM_PREFIX } from "./tables-IrLjsomf.js";
7
7
  import { t as Seed } from "./seed-BObmdKdP.js";
8
8
  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-yVzFCjxU.js";
10
- import { i as checkRoles, n as authPerms, r as checkAuth } from "./create-auth-Byf1-VJw.js";
11
- import { C as syncRoles, S as seedAs, _ as cookieOf, a as authModels, b as permissionsOf, c as hashOf, d as GoogleError, f as consentUrl, g as cookie, h as verifyIdToken, i as makers, l as sessions, n as TENANT, p as exchangeCode, r as checkTenancy, s as clearedCookie, t as PLATFORM, v as redirect, x as roleKeys, y as safeReturn } from "./config-D6PexpSC.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";
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";
@@ -61,10 +61,10 @@ var Router = class {
61
61
  //#endregion
62
62
  //#region src/api/output.ts
63
63
  /**
64
- * What a handler returns, as JSON (planning/requests.md → Output). Rows the ORM returned are found
64
+ * What a handler returns, as JSON. Rows the ORM returned are found
65
65
  * anywhere in the value by their tag: each field goes out in its kind's JSON form, and hidden
66
66
  * fields (`password`, `secret`, hidden slots) never leave. A file field's files go out as a client
67
- * sees them, with a `url` and without where they're kept (planning/uploads.md → Reading).
67
+ * sees them, with a `url` and without where they're kept.
68
68
  */
69
69
  /** The value as JSON, each file with its URL. */
70
70
  async function toJSON(value, urlOf) {
@@ -109,20 +109,39 @@ function rowJSON(row, model, files) {
109
109
  }
110
110
  return out;
111
111
  }
112
- /** A file as a client sees it: its id, its `url`, then what's shown of it; never its storage or key. */
112
+ /**
113
+ * A file as a client sees it: its id, its `url`, then what's shown of it, and an image's `sizes`
114
+ * (each `{ width, height, url }`, widest first); never its storage or keys.
115
+ */
113
116
  function fileJSON(file, files) {
114
- const { id, storage: _storage, key: _key, ...shown } = file;
117
+ const { id, storage: _storage, key: _key, sizes, ...shown } = file;
115
118
  const out = {
116
119
  id,
117
120
  ...files ? { url: null } : {},
118
121
  ...shown
119
122
  };
120
- if (!files) return out;
121
- const url = files.urlOf(file);
122
- if (typeof url === "string") out.url = url;
123
- else files.pending.push(url.then((made) => {
124
- out.url = made;
125
- }));
123
+ const linked = (into, of) => {
124
+ if (!files) return;
125
+ const url = files.urlOf(of);
126
+ if (typeof url === "string") into.url = url;
127
+ else files.pending.push(url.then((made) => {
128
+ into.url = made;
129
+ }));
130
+ };
131
+ linked(out, file);
132
+ if (sizes?.length) out.sizes = sizes.map(({ key, type, width, height }) => {
133
+ const one = {
134
+ width,
135
+ height,
136
+ ...files ? { url: null } : {}
137
+ };
138
+ linked(one, {
139
+ ...file,
140
+ key,
141
+ type
142
+ });
143
+ return one;
144
+ });
126
145
  return out;
127
146
  }
128
147
  //#endregion
@@ -354,7 +373,7 @@ function secure(response, url) {
354
373
  //#endregion
355
374
  //#region src/auth/boundary.ts
356
375
  /**
357
- * The route context's `db` (planning/policies.md → Enforcement): the same queries as `app.db`,
376
+ * The route context's `db`: the same queries as `app.db`,
358
377
  * but each one ends in `.allowed(action)` (the model's policy applies) or `.unchecked()` (it
359
378
  * doesn't, visibly). Until then it can't be awaited: a type error, and a rejection at runtime.
360
379
  *
@@ -976,7 +995,7 @@ function rescope(node, scope) {
976
995
  //#endregion
977
996
  //#region src/resources/custom.ts
978
997
  /**
979
- * A resource's custom actions (planning/resources.md, planning/requests.md → Custom actions):
998
+ * A resource's custom actions:
980
999
  * `POST /api/<r>/:key/<action>` (a singleton's: `/api/<r>/<action>`) runs the app's service on one
981
1000
  * row:
982
1001
  *
@@ -989,7 +1008,7 @@ function rescope(node, scope) {
989
1008
  * and the action's own rule must hold on it (else 403, or 401 anonymous). Then the service runs:
990
1009
  * trusted, with `app.db`, given the row and `{ input, user, tenant }`. What it returns is the
991
1010
  * response, as a route's is (`undefined` → 204). With the audit log on, the action is an entry on
992
- * the row (planning/audit.md).
1011
+ * the row.
993
1012
  */
994
1013
  async function runCustom(resource, action, context) {
995
1014
  const declared = resource.custom[action];
@@ -1047,7 +1066,7 @@ async function assertHeld(resource, context, rowId, action) {
1047
1066
  //#endregion
1048
1067
  //#region src/audit/entries.ts
1049
1068
  /**
1050
- * Audit entries as a client reads them (planning/audit.md → Reading): each changed field's
1069
+ * Audit entries as a client reads them: each changed field's
1051
1070
  * `[before, after]` back from how it was stored to its kind's JSON form. A hidden field (a secret,
1052
1071
  * a hidden slot) is `"changed"`, and a file field shows its files' ids, never where they're kept.
1053
1072
  */
@@ -1179,6 +1198,8 @@ function filesOf(model, row) {
1179
1198
  if (!row) return [];
1180
1199
  return Object.entries(model.spec.fields).flatMap(([field, spec]) => spec.kind.spec.file ? listOf(row[field]) : []);
1181
1200
  }
1201
+ /** Every key a file is stored at: its own, and its sizes'. */
1202
+ const keysOf = (file) => [file.key, ...(file.sizes ?? []).map((one) => one.key)];
1182
1203
  /** The files a write dropped: held before, held no longer. */
1183
1204
  function droppedFiles(before, after) {
1184
1205
  const kept = new Set(after.map((file) => file.id));
@@ -1264,20 +1285,20 @@ function fileWrites(upload, storages) {
1264
1285
  },
1265
1286
  settle: async (db, uploads, dropped = []) => {
1266
1287
  if (uploads.length > 0) await db.deleteMany(upload).where((u) => u.fileId.in(uploads));
1267
- await Promise.all(dropped.map(async (file) => {
1288
+ await Promise.all(dropped.flatMap((file) => keysOf(file).map(async (key) => {
1268
1289
  try {
1269
- await storages[file.storage]?.delete(file.key);
1290
+ await storages[file.storage]?.delete(key);
1270
1291
  } catch (error) {
1271
- console.error(`isai: couldn't remove ${file.key} from storage "${file.storage}"`, error);
1292
+ console.error(`isai: couldn't remove ${key} from storage "${file.storage}"`, error);
1272
1293
  }
1273
- }));
1294
+ })));
1274
1295
  }
1275
1296
  };
1276
1297
  }
1277
1298
  //#endregion
1278
1299
  //#region src/resources/write.ts
1279
1300
  /**
1280
- * A resource's writes and trait actions (planning/resources.md), through the route's `db`, so the
1301
+ * A resource's writes and trait actions, through the route's `db`, so the
1281
1302
  * model's policy applies (404 / 403 / 401) and the resource can only narrow it:
1282
1303
  *
1283
1304
  * POST /api/<r> create: only `writable` fields; the resource's `filter` holds for
@@ -1290,7 +1311,7 @@ function fileWrites(upload, storages) {
1290
1311
  * the resource and allowed `reorder`; 204
1291
1312
  *
1292
1313
  * A ref written must name a row the user may read (else a 400 on that field). A file field takes file
1293
- * ids: the row's own files, or uploads pending for it (planning/uploads.md); once the write succeeds
1314
+ * ids: the row's own files, or uploads pending for it; once the write succeeds
1294
1315
  * they're attached, and the files it dropped (a delete drops them all) are removed from storage.
1295
1316
  * Creates and updates answer with the row in the resource's shape, read back through it.
1296
1317
  */
@@ -1483,7 +1504,7 @@ async function reorder(resource, context) {
1483
1504
  //#endregion
1484
1505
  //#region src/resources/mount.ts
1485
1506
  /**
1486
- * Mounting resources (planning/resources.md → Generated): each resource an app module exports is
1507
+ * Mounting resources: each resource an app module exports is
1487
1508
  * mounted at `/api/<export name in kebab-case>`, one route per action:
1488
1509
  *
1489
1510
  * read GET /api/products · GET /api/products/:key (a singleton: GET /api/store)
@@ -1574,7 +1595,7 @@ const engine = (resource, action, context, files) => {
1574
1595
  //#endregion
1575
1596
  //#region src/audit/routes.ts
1576
1597
  /**
1577
- * The whole log (planning/audit.md → Reading), a built-in route:
1598
+ * The whole log, a built-in route:
1578
1599
  *
1579
1600
  * GET /api/audit?model=Variant&user=01H…&createdAt__gte=2026-10-21 newest first audit.read
1580
1601
  *
@@ -1840,7 +1861,7 @@ async function verify(token, purpose, secret, now) {
1840
1861
  //#endregion
1841
1862
  //#region src/auth/google.ts
1842
1863
  /**
1843
- * Sign-in with Google (planning/auth.md → Sign-in with Google). Google redirects only to URIs
1864
+ * Sign-in with Google. Google redirects only to URIs
1844
1865
  * registered in advance, so with tenancy the redirect flow goes through one callback on the
1845
1866
  * platform's host and hands the sign-in back to the tenant's host; without, the callback is on the
1846
1867
  * app's own host and there's no hand-off.
@@ -2040,7 +2061,7 @@ function googleHost(options) {
2040
2061
  //#endregion
2041
2062
  //#region src/auth/people.ts
2042
2063
  /**
2043
- * People and invites (planning/auth.md → Endpoints), built-in routes under `/api/auth/`:
2064
+ * People and invites, built-in routes under `/api/auth/`:
2044
2065
  *
2045
2066
  * GET /api/auth/users the people with accounts, with their roles manageUsers
2046
2067
  * POST /api/auth/users/:id/roles { role } gives a role manageUsers
@@ -2276,7 +2297,7 @@ async function invite(db, models, input) {
2276
2297
  //#endregion
2277
2298
  //#region src/auth/routes.ts
2278
2299
  /**
2279
- * The auth module's built-in routes (planning/auth.md → Endpoints), under `/api/auth/`, the same in
2300
+ * The auth module's built-in routes, under `/api/auth/`, the same in
2280
2301
  * every app:
2281
2302
  *
2282
2303
  * GET /api/auth/me the user, their roles and permission keys (for the UI), or 401
@@ -2313,7 +2334,7 @@ function authRoutes(models, sessions, db, allKeys) {
2313
2334
  //#endregion
2314
2335
  //#region src/files/targets.ts
2315
2336
  /**
2316
- * Upload targets (planning/uploads.md → Uploading): where a file may be uploaded to, named by the
2337
+ * Upload targets: where a file may be uploaded to, named by the
2317
2338
  * resource or route that will take it and its field, both in kebab-case. The target says who may
2318
2339
  * upload and which limits apply, so there's no upload config:
2319
2340
  *
@@ -2369,7 +2390,7 @@ function uploadTargets(modules, resources) {
2369
2390
  //#endregion
2370
2391
  //#region src/files/sniff.ts
2371
2392
  /**
2372
- * What a file is, from its first bytes (planning/uploads.md): its media type, never the one the
2393
+ * What a file is, from its first bytes: its media type, never the one the
2373
2394
  * client declares, and an image's width and height. A file whose bytes say nothing (text, an
2374
2395
  * Office file inside a zip) is told apart by its name's extension.
2375
2396
  */
@@ -2532,7 +2553,7 @@ async function headOf(stream, bytes) {
2532
2553
  //#endregion
2533
2554
  //#region src/files/uploads.ts
2534
2555
  /**
2535
- * Uploading (planning/uploads.md → Uploading): `POST /api/uploads/<target>/<field>` takes one file
2556
+ * Uploading: `POST /api/uploads/<target>/<field>` takes one file
2536
2557
  * as the raw body, with its name in `X-File-Name` (URI-encoded) and its size in `Content-Length`.
2537
2558
  *
2538
2559
  * 1. the target: who may upload, and the field's kind (storage, types, size)
@@ -2542,6 +2563,11 @@ async function headOf(stream, bytes) {
2542
2563
  * 5. an `Upload` row: pending until a write names the file's id, for a day
2543
2564
  *
2544
2565
  * It answers 201 with the file as a client sees it, its `url` included.
2566
+ *
2567
+ * An image's smaller copy (the client makes them: `srcset` picks one) is sent the same way, with
2568
+ * `X-Size-Of: <the image's id>`. It's kept with that image while its upload is still pending, from
2569
+ * the same user to the same field, and must be smaller than it: the answer is the image, its
2570
+ * `sizes` included. A write then names only the image.
2545
2571
  */
2546
2572
  /** How long an upload waits to be attached. */
2547
2573
  const UPLOAD_TTL_MS = 864e5;
@@ -2579,17 +2605,50 @@ function uploadRoute(options) {
2579
2605
  await body.cancel().catch(() => {});
2580
2606
  throw fileTypeRefused(fileTypeNames(spec.types));
2581
2607
  }
2608
+ const db = accessOf(context.db).trusted;
2609
+ const user = context.user;
2610
+ const put = async (key) => {
2611
+ try {
2612
+ await storage.put(key, body, {
2613
+ size,
2614
+ contentType: sniffed.type
2615
+ });
2616
+ } catch (error) {
2617
+ if (error instanceof WrongSize) throw new HttpError(400, "WrongSize", "The file's bytes don't match its Content-Length");
2618
+ throw error;
2619
+ }
2620
+ };
2621
+ const sizeOf = request.headers.get("X-Size-Of");
2622
+ if (sizeOf !== null) {
2623
+ const pending = await db.findOne(options.upload, { fileId: sizeOf });
2624
+ const image = pending?.file;
2625
+ const refused = (code, message) => {
2626
+ body.cancel().catch(() => {});
2627
+ return new HttpError(400, code, message);
2628
+ };
2629
+ if (!pending || !image || pending.target !== name || (pending.user ?? void 0) !== user?.id || pending.expiresAt.getTime() <= clockOf(db).now().getTime()) throw refused("SizeOfUnknown", "X-Size-Of must name an image uploaded here, before it's attached");
2630
+ const { width, height } = sniffed;
2631
+ const sizes = image.sizes ?? [];
2632
+ if (!width || !height || !image.width || !image.height || width >= image.width || height > image.height) throw refused("NotSmaller", "A size must be an image smaller than the one it's of");
2633
+ if (sizes.length >= 4 || sizes.some((one) => one.width === width)) throw refused("TooManySizes", `An image keeps at most 4 sizes, each of its own width`);
2634
+ const key = `${image.key}.${width}`;
2635
+ await put(key);
2636
+ const file = {
2637
+ ...image,
2638
+ sizes: [...sizes, {
2639
+ key,
2640
+ size,
2641
+ type: sniffed.type,
2642
+ width,
2643
+ height
2644
+ }].sort((a, b) => b.width - a.width)
2645
+ };
2646
+ await db.updateOne(options.upload, { fileId: sizeOf }, { file });
2647
+ return json(201, await options.answer(file, new URL(request.url).origin));
2648
+ }
2582
2649
  const id = newFileId();
2583
2650
  const key = keyOf(id, context.tenant);
2584
- try {
2585
- await storage.put(key, body, {
2586
- size,
2587
- contentType: sniffed.type
2588
- });
2589
- } catch (error) {
2590
- if (error instanceof WrongSize) throw new HttpError(400, "WrongSize", "The file's bytes don't match its Content-Length");
2591
- throw error;
2592
- }
2651
+ await put(key);
2593
2652
  const file = {
2594
2653
  id,
2595
2654
  storage: spec.storage,
@@ -2598,8 +2657,6 @@ function uploadRoute(options) {
2598
2657
  size,
2599
2658
  ...sniffed
2600
2659
  };
2601
- const db = accessOf(context.db).trusted;
2602
- const user = context.user;
2603
2660
  await db.createOne(options.upload, {
2604
2661
  fileId: id,
2605
2662
  file,
@@ -2622,7 +2679,7 @@ function nameOf(header) {
2622
2679
  //#endregion
2623
2680
  //#region src/files/links.ts
2624
2681
  /**
2625
- * Where a client reads a file (planning/uploads.md → Reading): a public storage's file at its bucket's
2682
+ * Where a client reads a file: a public storage's file at its bucket's
2626
2683
  * URL; a private storage's through a signed link the app serves, `/api/files/<id>?t=<signed>`.
2627
2684
  *
2628
2685
  * The link is made while writing a row the user may read, so making it is the access check. It
@@ -2720,7 +2777,7 @@ function appFiles(options) {
2720
2777
  return storage;
2721
2778
  };
2722
2779
  return {
2723
- put: async (body, { name, storage = "default", size: given }) => {
2780
+ put: async (body, { name, storage = "default", size: given, sizes: copies = [] }) => {
2724
2781
  const where = storageOf(storage);
2725
2782
  const bytes = body instanceof ReadableStream ? void 0 : asBytes(body);
2726
2783
  const size = bytes?.byteLength ?? given;
@@ -2740,18 +2797,40 @@ function appFiles(options) {
2740
2797
  size,
2741
2798
  contentType: sniffed.type
2742
2799
  });
2800
+ const sizes = [];
2801
+ for (const copy of copies) {
2802
+ const bytes = new Uint8Array(copy);
2803
+ const one = sniff(bytes.subarray(0, SNIFF_BYTES), name);
2804
+ if (!one?.width || !one.height || !sniffed.width || !sniffed.height || one.width >= sniffed.width || one.height > sniffed.height) throw new TypeError(`isai: a size of ${name} must be an image smaller than it`);
2805
+ const at = `${key}.${one.width}`;
2806
+ await where.put(at, bytes, {
2807
+ size: bytes.byteLength,
2808
+ contentType: one.type
2809
+ });
2810
+ sizes.push({
2811
+ key: at,
2812
+ size: bytes.byteLength,
2813
+ type: one.type,
2814
+ width: one.width,
2815
+ height: one.height
2816
+ });
2817
+ }
2818
+ sizes.sort((a, b) => b.width - a.width);
2743
2819
  return {
2744
2820
  id,
2745
2821
  storage,
2746
2822
  key,
2747
2823
  name,
2748
2824
  size,
2749
- ...sniffed
2825
+ ...sniffed,
2826
+ ...sizes.length ? { sizes } : {}
2750
2827
  };
2751
2828
  },
2752
2829
  get: async (file) => (await storageOf(file.storage).get(file.key))?.body,
2753
2830
  url: async (file, { origin = "" } = {}) => url(file, origin),
2754
- delete: (file) => storageOf(file.storage).delete(file.key)
2831
+ delete: async (file) => {
2832
+ await Promise.all(keysOf(file).map((key) => storageOf(file.storage).delete(key)));
2833
+ }
2755
2834
  };
2756
2835
  }
2757
2836
  function asBytes(body) {
@@ -2760,8 +2839,25 @@ function asBytes(body) {
2760
2839
  //#endregion
2761
2840
  //#region src/files/index.ts
2762
2841
  /**
2763
- * The files module (planning/uploads.md): uploads into the app's storages, file ids in writes, and
2764
- * each file's `url` on the way out.
2842
+ * The files module: uploads into the app's storages, file ids in writes, and each file's `url` on
2843
+ * the way out.
2844
+ *
2845
+ * storage: { default: r2(env.MEDIA, { publicUrl: env.MEDIA_URL }), private: r2(env.FILES) } // app.ts
2846
+ * images: f.images() // a model field: the kind owns count, size and type
2847
+ * files: f.files({ storage: "private" }) // a storage other than `default`
2848
+ *
2849
+ * POST /api/uploads/manage-products/images raw bytes → 201 { id: "fil_…", url, … } (pending)
2850
+ * PATCH /api/manage-products/:key { images: ["fil_…", …] } attaches them, in that order
2851
+ *
2852
+ * Upload first, then reference by id: a form uploads each file while it's being filled, and Save is
2853
+ * a small JSON write. Uploads go through the app, not straight to the bucket: it checks the target,
2854
+ * the type (from the bytes) and the size before anything lands, with no S3 credentials beside the
2855
+ * binding and no CORS on the bucket; a presigned upload only pays past the Workers body limit.
2856
+ *
2857
+ * Visibility belongs to the storage (a `publicUrl` or not), never a flag that could disagree with
2858
+ * the bucket. Keys are the framework's (`<tenant>/<fileId>`, or `<fileId>` without tenancy); app
2859
+ * code never builds a key, path or URL. A stored value keeps its storage and key, so old files keep
2860
+ * working if a field's storage changes.
2765
2861
  */
2766
2862
  /**
2767
2863
  * Checks that every file field (a model's or a route input's) stores in a storage `app.ts` has, and
@@ -2838,7 +2934,7 @@ function files(options) {
2838
2934
  //#endregion
2839
2935
  //#region src/tenancy/front-door.ts
2840
2936
  /**
2841
- * The front door's tenant step (planning/tenancy.md → The front door): the host decides. The
2937
+ * The front door's tenant step: the host decides. The
2842
2938
  * platform's host is the platform's; any other is a `Domain` row, behind which is a `Tenant`. A
2843
2939
  * suspended tenant answers 503; an active one gets the whole request, where it lives.
2844
2940
  */
@@ -2892,7 +2988,7 @@ async function releaseOf(manifest) {
2892
2988
  //#endregion
2893
2989
  //#region src/tenancy/services.ts
2894
2990
  /**
2895
- * The app's services by reference (planning/tenancy.md → Creating tenants): what crosses into a
2991
+ * The app's services by reference: what crosses into a
2896
2992
  * tenant is a service's place among `createApp`'s `services` modules and its export name, never
2897
2993
  * the function. The Worker and every tenant's object load the same bundle, so a reference names
2898
2994
  * the same function on both sides.
@@ -3155,7 +3251,7 @@ function createCore(config, env, options = {}) {
3155
3251
  },
3156
3252
  platform: { get db() {
3157
3253
  if (!platform) throw new TypeError("isai: app.platform.db is the platform's database, which only an app with isolated tenancy has");
3158
- if (scope.current()) throw new TypeError("isai: app.platform.db isn't reachable inside a tenant; a tenant's own row is ctx.tenant (planning/tenancy.md)");
3254
+ if (scope.current()) throw new TypeError("isai: app.platform.db isn't reachable inside a tenant; a tenant's own row is ctx.tenant");
3159
3255
  return platform;
3160
3256
  } },
3161
3257
  files: appFiles({
@@ -1,6 +1,8 @@
1
1
  //#region src/validation/issues.d.ts
2
2
  /** What a failed validation reports: one issue per problem, each at a path into the value. */
3
- type IssueCode = "required" | "type" | "format" | "tooShort" | "tooLong" | "tooSmall" | "tooLarge" | "notInteger" | "notAllowed" | "unknown" | "fixed";
3
+ type IssueCode = "required" | "type" | "format" | "tooShort" | "tooLong" | "tooSmall" | "tooLarge" | "notInteger" | "notAllowed" | "unknown" | "fixed" |
4
+ /** Another row already has this value (a unique, or a slug made from this field). */
5
+ "taken";
4
6
  /** Where in the value: field names and list indexes (`["members", 2, "email"]`). */
5
7
  type Path = readonly (string | number)[];
6
8
  interface Issue {
@@ -1,4 +1,4 @@
1
- import { F as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-C65RM_K-.js";
1
+ import { F as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-BU3_PHk8.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) {
@@ -76,7 +76,22 @@ function cleanupMigration(options) {
76
76
  *
77
77
  * Generating never reads the live database. Where existing rows may clash with a change, it
78
78
  * writes the data step that resolves them, from an answer or with a `TODO` that fails the deploy
79
- * until it's filled in (planning/migrations.md → Generating).
79
+ * until it's filled in.
80
+ *
81
+ * add a model or a field CREATE TABLE / ADD COLUMN (nullable in SQL); a fill data step
82
+ * for existing rows (the default, or a value asked)
83
+ * add a count, copy or sequence ADD COLUMN, its triggers, a fill computed from it
84
+ * remove a field or a model nothing dropped: retired until `isai cleanup` (its indexes,
85
+ * counts and copies stay; a required one is filled in new rows)
86
+ * rename nothing: `.column("old_name")` / `.table("old")`, asked about
87
+ * loosen a one-field rule nothing: code only
88
+ * tighten one, or add a check a check data step: passes when no row breaks it, else a `fix`
89
+ * add a unique a resolution step before the index (duplicates as chosen)
90
+ * remove a unique retired until cleanup (an earlier release may rely on it)
91
+ *
92
+ * Refused, each with the safe sequence: an incompatible kind change (or toggling `secret`), a field
93
+ * becoming or ceasing to be kept by the database, a new required unique field over existing rows,
94
+ * a rebuild (a ref's `onDelete`, an FK on an existing column), reusing a retired name.
80
95
  */
81
96
  const pad = (n) => String(n).padStart(4, "0");
82
97
  const json = (value) => JSON.stringify(value);
@@ -1,6 +1,6 @@
1
- import { G as Fake, K as fakeValue } from "./validation-l5JG-PFn.js";
2
- import { D as planInsert, E as recordAction, S as clockOf, T as forUpkeep, V as CheckFailed } from "./migrator-C65RM_K-.js";
3
- import { j as IMPLICIT_FIELDS, n as toBase64url, r as Model, t as fromBase64url } from "./base64url-DwWPqoOT.js";
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";
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. */
@@ -329,8 +329,15 @@ function authOf(models) {
329
329
  //#endregion
330
330
  //#region src/db/seeds/makers.ts
331
331
  /**
332
- * `make` and `many`: a row from the values given, the rest faked from each field's kind
333
- * (planning/data-layer.md → Seeds and fakes). Models carry no factory code.
332
+ * `make` and `many`: a row from the values given, the rest faked from each field's kind. Models
333
+ * carry no factory code; values that matter to a story go in the seed.
334
+ *
335
+ * For a field it isn't given, `make` writes its default if it has one; nothing for what the
336
+ * framework or the database makes (id, timestamps, generated, maintained); a new parent for a
337
+ * required ref; `null` for an optional field (refs too), so rows are minimal; else the kind's
338
+ * fake, numbered for a unique field so fakes don't collide. A faked row failing a check is drawn
339
+ * again a few times, then `make` fails naming the check. Writes go through `createOne`, so
340
+ * validation, guards and the write stream all run.
334
341
  */
335
342
  /** Fake rows that fail a check are drawn again, this many times in all. */
336
343
  const ATTEMPTS = 20;
@@ -403,37 +410,4 @@ function failedCheck(model, input, now) {
403
410
  }
404
411
  }
405
412
  //#endregion
406
- //#region src/tenancy/config.ts
407
- /**
408
- * The two database names that mean something, and only with tenancy: each tenant's own database,
409
- * and the platform's (planning/tenancy.md → Configuration). Every other name is just a name.
410
- */
411
- const TENANT = "tenant";
412
- const PLATFORM = "platform";
413
- /** A host without a scheme, path or trailing dot; a port is allowed (local development). */
414
- const HOST = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::\d{1,5})?$/;
415
- /**
416
- * Checks the tenancy key against the databases, when the app starts. With tenancy the databases
417
- * are `tenant` (each tenant's) and `platform`; without it, the app has one database, named freely.
418
- */
419
- function checkTenancy(tenancy, databases) {
420
- const names = Object.keys(databases);
421
- if (!tenancy) {
422
- const reserved = names.find((name) => name === "tenant" || name === "platform");
423
- if (reserved) throw new TypeError(`isai: databases.${reserved} is ${reserved === "tenant" ? "each tenant's database" : "the platform's database"}, which only an app with a \`tenancy\` key has; name the app's database something else (main)`);
424
- if (names.length !== 1) throw new TypeError(`isai: an app without tenancy has one database (named as you like); it has ${names.length}${names.length > 1 ? `: ${names.join(", ")}. Several aren't supported yet` : ""}`);
425
- if (Object.values(databases)[0]?.kind !== "database") throw new TypeError("isai: every tenant's own database (durableObject(env.TENANT)) needs a `tenancy` key");
426
- return;
427
- }
428
- if (tenancy.mode !== "isolated") throw new TypeError(`isai: tenancy.mode "${String(tenancy.mode)}" isn't built yet; use "isolated"`);
429
- if (tenancy.by !== "host") throw new TypeError(`isai: tenancy.by "${String(tenancy.by)}" isn't built yet; use "host"`);
430
- if (typeof tenancy.platform !== "string" || !HOST.test(tenancy.platform.toLowerCase())) throw new TypeError(`isai: tenancy.platform must be a host, like app.example.com (no https:// or path); got "${String(tenancy.platform)}"`);
431
- const model = tenancy.tenant;
432
- if (model?.spec?.name !== "Tenant" || model.spec.builtin !== "tenancy") throw new TypeError("isai: tenancy.tenant is the Tenant model: builtins.Tenant, or its extension (import { Tenant } from \"#db/platform/tenants\")");
433
- if (databases["tenant"]?.kind !== "tenantDatabases") throw new TypeError("isai: with isolated tenancy, databases.tenant is each tenant's database: durableObject(env.TENANT)");
434
- if (databases["platform"]?.kind !== "database") throw new TypeError("isai: an app with tenancy needs databases.platform, where the Tenant and Domain tables live");
435
- const other = names.find((name) => name !== "tenant" && name !== "platform");
436
- if (other) throw new TypeError(`isai: with tenancy, the databases are tenant and platform; databases.${other} isn't supported yet`);
437
- }
438
- //#endregion
439
- export { syncRoles as C, seedAs as S, cookieOf as _, authModels as a, permissionsOf as b, hashOf as c, GoogleError as d, consentUrl as f, cookie as g, verifyIdToken as h, makers as i, sessions as l, forgetGoogleKeys as m, TENANT as n, authOf as o, exchangeCode as p, checkTenancy as r, clearedCookie as s, PLATFORM as t, GOOGLE as u, redirect as v, roleKeys as x, safeReturn as y };
413
+ export { permissionsOf as _, hashOf as a, syncRoles as b, GoogleError as c, forgetGoogleKeys as d, verifyIdToken as f, safeReturn as g, redirect as h, clearedCookie as i, consentUrl as l, cookieOf as m, authModels as n, sessions as o, cookie as p, authOf as r, GOOGLE as s, makers as t, exchangeCode as u, roleKeys as v, seedAs as y };