@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
@@ -1,4 +1,4 @@
1
- import { r as Cache } from "../../../index-BKwTOLop.js";
1
+ import { r as Cache } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/cache/cloudflare-kv/index.d.ts
3
3
  /** The parts of a KV binding this cache uses. */
4
4
  export interface KvBinding {
@@ -1,4 +1,4 @@
1
- import { a as DatabaseDriver, f as Row } from "../../../index-BKwTOLop.js";
1
+ import { a as DatabaseDriver, f as Row } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/database/d1/index.d.ts
3
3
  /** The parts of a D1 prepared statement this driver uses. */
4
4
  export interface D1Statement {
@@ -1,4 +1,4 @@
1
- import { a as DatabaseDriver, b as TenantDatabases, f as Row } from "../../../index-BKwTOLop.js";
1
+ import { a as DatabaseDriver, b as TenantDatabases, f as Row } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/database/durable-object/index.d.ts
3
3
  /** The parts of `ctx.storage` this driver uses (structurally compatible with `wrangler types`). */
4
4
  export interface DurableObjectStorageBinding {
@@ -1,4 +1,4 @@
1
- import { a as DatabaseDriver } from "../../../index-BKwTOLop.js";
1
+ import { a as DatabaseDriver } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/database/sqlite-node/index.d.ts
3
3
  export interface SqliteNodeDriver extends DatabaseDriver {
4
4
  close(): void;
@@ -1,4 +1,4 @@
1
- import { s as MailDriver } from "../../../index-BKwTOLop.js";
1
+ import { s as MailDriver } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/mail/gmail/index.d.ts
3
3
  export interface GmailOptions {
4
4
  readonly clientId: string;
@@ -1,4 +1,4 @@
1
- import { l as PdfRenderer } from "../../../index-BKwTOLop.js";
1
+ import { l as PdfRenderer } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/pdf/cloudflare-browser/index.d.ts
3
3
  /** A Browser Rendering binding (opaque here; used through @cloudflare/puppeteer). */
4
4
  export type BrowserBinding = object;
@@ -1,4 +1,4 @@
1
- import { u as Queue } from "../../../index-BKwTOLop.js";
1
+ import { u as Queue } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/queue/cloudflare/index.d.ts
3
3
  /** The parts of a Queues producer binding this queue uses. */
4
4
  export interface QueueBinding {
@@ -1,4 +1,4 @@
1
- import { d as RateLimiter } from "../../../index-BKwTOLop.js";
1
+ import { d as RateLimiter } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/ratelimit/cloudflare/index.d.ts
3
3
  /** The part of a Rate Limiting binding this uses. */
4
4
  export interface RateLimitBinding {
@@ -1,4 +1,4 @@
1
- import { d as RateLimiter } from "../../../index-BKwTOLop.js";
1
+ import { d as RateLimiter } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/ratelimit/memory/index.d.ts
3
3
  export interface MemoryRateLimitOptions {
4
4
  /** Requests a key may make in a window. */
@@ -1,4 +1,4 @@
1
- import { f as Row, m as ServiceRef, p as Runtime, v as TaskMessage } from "../../../index-BKwTOLop.js";
1
+ import { f as Row, m as ServiceRef, p as Runtime, v as TaskMessage } from "../../../index-Ce5p2-EB.js";
2
2
  import { DurableObject } from "cloudflare:workers";
3
3
  //#region src/adapters/runtime/cloudflare/index.d.ts
4
4
  /** The parts of the Workers platform this runtime uses (structurally compatible with `wrangler types`). */
@@ -11,7 +11,7 @@ import { DurableObject } from "cloudflare:workers";
11
11
  *
12
12
  * With isolated tenancy, `Tenant` is the Durable Object class each tenant lives in: it opens the
13
13
  * tenant from its own storage, migrates it before any request (`blockConcurrencyWhile`), and
14
- * handles what the front door forwards to it over RPC (planning/tenancy.md).
14
+ * handles what the front door forwards to it over RPC.
15
15
  */
16
16
  /**
17
17
  * One failing task never holds back the rest of its batch: each message is acked on
@@ -1,4 +1,4 @@
1
- import { g as Storage } from "../../../index-BKwTOLop.js";
1
+ import { g as Storage } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/storage/disk/index.d.ts
3
3
  export interface DiskStorageOptions {
4
4
  /** The folder files are kept in, made when missing. */
@@ -1,4 +1,4 @@
1
- import { g as Storage } from "../../../index-BKwTOLop.js";
1
+ import { g as Storage } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/storage/memory/index.d.ts
3
3
  export interface MemoryStorageOptions {
4
4
  /** Where its files would be served from, to make it a public storage. */
@@ -1,4 +1,4 @@
1
- import { g as Storage } from "../../../index-BKwTOLop.js";
1
+ import { g as Storage } from "../../../index-Ce5p2-EB.js";
2
2
  //#region src/adapters/storage/r2/index.d.ts
3
3
  /** The parts of an R2 binding this storage uses. */
4
4
  export interface R2Binding {
@@ -1,3 +1,3 @@
1
- import { r as AuthTablesOptions } from "../data-layer-BMviRJaW.js";
2
- import { A as Permissions, F as DefinePolicy, I as Perms, L as Policy, N as AnyPolicy, P as Bound, R as Rules, a as OAuthMethod, c as Roles, i as Methods, l as SignInMethod, n as AuthConfig, o as Provider, r as AuthDefinition, s as RoleDefinition, t as Auth, u as createAuth, z as StandardAction } from "../index-CBWFoL3s.js";
1
+ import { r as AuthTablesOptions } from "../data-layer-D1BFo3wO.js";
2
+ import { A as Permissions, F as DefinePolicy, I as Perms, L as Policy, N as AnyPolicy, P as Bound, R as Rules, a as OAuthMethod, c as Roles, i as Methods, l as SignInMethod, n as AuthConfig, o as Provider, r as AuthDefinition, s as RoleDefinition, t as Auth, u as createAuth, z as StandardAction } from "../index-DlgVg3D8.js";
3
3
  export { type AnyPolicy, Auth, type AuthConfig, type AuthDefinition, type AuthTablesOptions, type Bound, type DefinePolicy, type Methods, type OAuthMethod, type Permissions, type Perms, Policy, type Provider, type RoleDefinition, type Roles, type Rules, type SignInMethod, type StandardAction, createAuth };
@@ -1,3 +1,3 @@
1
- import { t as Policy } from "../policies-yVzFCjxU.js";
2
- import { a as createAuth, t as Auth } from "../create-auth-Byf1-VJw.js";
1
+ import { t as Policy } from "../policies-CKIdBFKt.js";
2
+ import { a as createAuth, t as Auth } from "../create-auth-Ci0PaBoX.js";
3
3
  export { Auth, Policy, createAuth };
@@ -1,4 +1,4 @@
1
- import { A as flag, C as textKind, D as date, E as choice, F as sequence, L as list, M as int, N as money, O as datetime, P as quantity, S as text, T as url, U as kind, _ as personName, b as slugify, c as images, d as urls, f as email, g as newToken, h as markdown, i as file, j as id, k as fakeUlid, l as define, m as line, n as phoneKind, o as files, s as image, u as json, v as secret, w as token, x as subdomain, y as slug } from "./validation-l5JG-PFn.js";
1
+ import { A as fakeUlid, C as text, D as choice, E as url, F as quantity, I as sequence, M as id, N as int, O as date, P as money, R as list, S as subdomain, T as token, W as kind, _ as newToken, a as file, b as slug, c as image, d as json, f as urls, g as markdown, h as line, j as flag, k as datetime, l as images, n as phoneKind, p as email, s as files, u as define, v as personName, w as textKind, x as slugify, y as secret } from "./validation-Cppmd0RV.js";
2
2
  import { n as snakeCase } from "./case-BnpcvhdW.js";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  //#region src/internal/scope.ts
@@ -73,9 +73,20 @@ const ulid = monotonicUlid();
73
73
  //#endregion
74
74
  //#region src/db/orm/audit.ts
75
75
  /**
76
- * The audit log in the ORM's write path (planning/audit.md): which writes are recorded, who makes
77
- * them, and the statements placed around each one in its batch, so a change and its entries commit
78
- * together (or neither does).
76
+ * The audit log in the ORM's write path: which writes are recorded, who makes them, and the
77
+ * statements placed around each one in its batch, so a change and its entries commit together (or
78
+ * neither does).
79
+ *
80
+ * Recorded: every ORM write (`…One`, `…Many`, upserts, inside `atomic` too), from routes,
81
+ * resources and services alike; a custom action's entry beside the writes it made (one request id
82
+ * ties them); sign-ins and sign-outs. Not recorded: data steps and the roles synced from `app.ts`
83
+ * (upkeep, nobody edited them), `db.raw`, `reorder` (positions are maintained), rows the database
84
+ * changes itself (cascades, counts, copies follow from a recorded write), reads, and the tables
85
+ * marked `unaudited` (Session, Verification, Upload, the log itself).
86
+ *
87
+ * Why in the batch, not from the post-commit write stream: a second write after commit can be lost
88
+ * while the change stays, and once committed the before values are gone. Why not triggers: a
89
+ * trigger can't see who wrote, and would record the database's own count and copy updates as noise.
79
90
  */
80
91
  const actors = new Scope();
81
92
  /** Runs `fn` with `actor` as the one every write inside it is recorded as made by. */
@@ -263,8 +274,11 @@ var Field = class Field {
263
274
  }
264
275
  /**
265
276
  * A copy of a related row's value (`.from((s) => s.criterion.maxScore)`), so the row's checks and
266
- * uniques can use it. The database writes it on create and keeps it equal to its source; it's
267
- * never written. The path's first ref must be `.fixed()`.
277
+ * uniques can use it. The database writes it on create and keeps it equal to its source (triggers
278
+ * on each table along the path, so raw SQL writes are seen too); it's never written. The path's
279
+ * first ref must be `.fixed()`; when every step and the final field are fixed, it's written once
280
+ * and no trigger is made. A plain path to one column only: a derived value is `.computed()`, and a
281
+ * value frozen as it was is a snapshot, not a copy.
268
282
  */
269
283
  from(build) {
270
284
  if (this.spec.maintained || this.spec.computed || this.spec.generate) throw new TypeError("isai: .from() is for a plain field (not one the database or the framework makes)");
@@ -331,7 +345,7 @@ function sensitivity(spec) {
331
345
  //#region src/db/fields/kinds.ts
332
346
  /**
333
347
  * The built-in field kinds: each is a kind from `isai/validation` with its storage. Kinds own
334
- * their limits and rules; columns never repeat them (planning/fields.md).
348
+ * their limits and rules; columns never repeat them.
335
349
  */
336
350
  function fieldSpec(kind, storage, options) {
337
351
  return {
@@ -429,7 +443,7 @@ function fieldKinds(defaults) {
429
443
  json: (options) => field(json(options.shape), "json"),
430
444
  /** A list, for shapes (`f.json({ shape: f.list(...) })`, route inputs). Not a column itself. */
431
445
  list,
432
- /** A file; `{ storage }` names a storage other than `default` (planning/uploads.md). */
446
+ /** A file; `{ storage }` names a storage other than `default`. */
433
447
  file: (options = {}) => field(file(options), "json"),
434
448
  image: (options = {}) => field(image(options), "json"),
435
449
  files: (options = {}) => field(files(options), "json"),
@@ -1067,7 +1081,7 @@ var Model = class Model {
1067
1081
  }
1068
1082
  /**
1069
1083
  * A rule over the row, written as a condition. It runs in JS on every write; when it reads
1070
- * more than one field it is also a database guard (planning/migrations.md).
1084
+ * more than one field it is also a database guard.
1071
1085
  */
1072
1086
  check(name, build) {
1073
1087
  assertIdentifier("a check name", name);
package/dist/cli/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { B as toKind, R as object } from "../validation-l5JG-PFn.js";
3
- import { M as dialectFor, m as emptySnapshot, r as sync, t as migrate, y as MigrationError } from "../migrator-C65RM_K-.js";
4
- import { a as lookupsOf, i as filterableOf, n as isRequired, t as Resource, u as Route } from "../resource-9fjxJgmz.js";
5
- import { F as sensitivity, c as fieldOf, r as Model } from "../base64url-DwWPqoOT.js";
2
+ import { V as toKind, z as object } from "../validation-Cppmd0RV.js";
3
+ import { M as dialectFor, m as emptySnapshot, r as sync, t as migrate, y as MigrationError } from "../migrator-BU3_PHk8.js";
4
+ import { a as lookupsOf, i as filterableOf, n as isRequired, t as Resource, u as Route } from "../resource-CsYksjKc.js";
5
+ import { F as sensitivity, c as fieldOf, r as Model } from "../base64url-C5GTYIL5.js";
6
6
  import { t as kebabCase } from "../case-BnpcvhdW.js";
7
- import { i as makeMigrations, n as checkSteps, t as checkMigrations } from "../make-DKzPd2b_.js";
7
+ import { i as makeMigrations, n as checkSteps, t as checkMigrations } from "../make-DKBn79cH.js";
8
8
  import { d1 } from "../adapters/database/d1/index.js";
9
9
  import { spawn } from "node:child_process";
10
10
  import { parseArgs } from "node:util";
@@ -198,13 +198,15 @@ function resourceMembers(named, custom, used) {
198
198
  }
199
199
  const spec = resource.model.spec;
200
200
  const uploads = resource.writable.filter((field) => spec.fields[field]?.kind.spec.file);
201
+ const images = uploads.filter((field) => spec.fields[field]?.kind.spec.file?.image);
201
202
  if (uploads.length > 0) calls.push(`upload(field: ${uploads.map((f) => JSON.stringify(f)).join(" | ")}, file: Blob): Promise<FileJSON>`);
202
203
  const endpoint = [
203
204
  `resource: ${JSON.stringify(`/${kebabCase(name)}`)}`,
204
205
  ...resource.singleton ? ["singleton: true"] : [],
205
206
  `actions: ${JSON.stringify(resource.actions)}`,
206
207
  ...Object.keys(resource.custom).length > 0 ? [`custom: ${JSON.stringify(Object.keys(resource.custom))}`] : [],
207
- ...uploads.length > 0 ? [`uploads: ${JSON.stringify(uploads)}`] : []
208
+ ...uploads.length > 0 ? [`uploads: ${JSON.stringify(uploads)}`] : [],
209
+ ...images.length > 0 ? [`images: ${JSON.stringify(images)}`] : []
208
210
  ];
209
211
  used.add("Page");
210
212
  return {
@@ -325,7 +327,7 @@ function json(text, side) {
325
327
  /**
326
328
  * The app's models: every export of every file in `db/schema/` that is a model, plus the built-in
327
329
  * tables `db/index.ts` switches on (`use`) that the app doesn't extend. `db/schema/` holds models
328
- * only (planning/README.md → Layout), so there's no list to keep. A model's database is its folder:
330
+ * only, so there's no list to keep. A model's database is its folder:
329
331
  * `db/schema/<database>/` (`tenant/` and `platform/` with tenancy, `main/` or any one name without).
330
332
  */
331
333
  const SCHEMA_DIR = "db/schema";
@@ -350,8 +352,9 @@ async function loadModels(project) {
350
352
  const name = value.spec.name;
351
353
  const other = found.get(name);
352
354
  if (other && other.model !== value) throw new Error(`isai: two models are named ${name} (db/schema/${other.file}.ts and ${file})`);
353
- const builtin = value.spec.database;
354
- if (builtin !== void 0 && builtin !== database) throw new Error(`isai: ${name} is a built-in table of the ${builtin} database, so it's extended in ${folderOf(builtin)}/ (not db/schema/${file})`);
355
+ const said = value.spec.database;
356
+ if (said !== void 0 && said !== database) throw new Error(value.spec.builtin !== void 0 ? `isai: ${name} is a built-in table of the ${said} database, so it's extended in ${folderOf(said)}/ (not db/schema/${file})` : `isai: ${name} says { database: "${said}" }, so it goes in ${folderOf(said)}/ (not db/schema/${file})`);
357
+ if (said === void 0 && database === "platform") throw new Error(`isai: ${name} is in db/schema/${file}, so it says so: model("${name}", { database: "platform" }, …)`);
355
358
  found.set(name, {
356
359
  model: value,
357
360
  file: file.slice(0, -3),
@@ -385,7 +388,7 @@ function databasesOf(models) {
385
388
  //#endregion
386
389
  //#region src/cli/outputs.ts
387
390
  /**
388
- * What handlers return, for the typed client (planning/client.md): each route's and custom
391
+ * What handlers return, for the typed client: each route's and custom
389
392
  * action's TypeScript return type, read with the compiler over the app's own tsconfig, and printed
390
393
  * as the JSON the output layer sends: dates (anything with `toJSON`) as what `toJSON` returns,
391
394
  * files as `FileJSON`, functions and symbol keys left out, `undefined` keys optional.
@@ -534,7 +537,7 @@ const key = (name) => IDENTIFIER.test(name) ? name : JSON.stringify(name);
534
537
  //#region src/cli/client.ts
535
538
  /**
536
539
  * `isai client` (and `isai dev`, on start and on each change): writes `.isai/client.ts`, the app's
537
- * typed client (planning/client.md); `isai check` fails on a stale one. The app modules (`apps/**`)
540
+ * typed client; `isai check` fails on a stale one. The app modules (`apps/**`)
538
541
  * are loaded as the app loads them, for the exact endpoints, inputs and rows; what their handlers
539
542
  * return is read with the TypeScript compiler.
540
543
  */
@@ -762,7 +765,7 @@ function printPlan(plan, log) {
762
765
  }
763
766
  /**
764
767
  * Applies every pending step to the local databases at once: structure, rules, then data steps.
765
- * No older code is running locally, so nothing waits for a release (planning/migrations.md).
768
+ * No older code is running locally, so nothing waits for a release.
766
769
  * Returns the steps applied, as `<database>/<step>`.
767
770
  */
768
771
  async function migrateLocal(project, databaseFor, log) {
@@ -792,7 +795,7 @@ async function migrateLocal(project, databaseFor, log) {
792
795
  //#region src/cli/project.ts
793
796
  /**
794
797
  * The app the CLI works on: its root, and how its TypeScript is imported: models (`db/schema/`),
795
- * migration manifests, and the app modules for the typed client (planning/client.md).
798
+ * migration manifests, and the app modules for the typed client.
796
799
  */
797
800
  /** The nearest directory upward from `from` with a `package.json`. */
798
801
  function findRoot(from) {
@@ -838,7 +841,7 @@ const PLATFORM_STUB = [
838
841
  ].join("\n");
839
842
  //#endregion
840
843
  //#region src/cli/prompt.ts
841
- /** Asks `makemigrations`' questions in a terminal (planning/migrations.md → Generating). */
844
+ /** Asks `makemigrations`' questions in a terminal. */
842
845
  /** A value typed at the prompt: JSON when it reads as JSON (`3`, `true`), the text otherwise. */
843
846
  function typedValue(text) {
844
847
  try {
@@ -898,7 +901,7 @@ async function answer(question, prompt) {
898
901
  /**
899
902
  * The `workers` target, from the CLI: the app's `wrangler.jsonc`, its local D1 (the database
900
903
  * `wrangler dev` uses), and `wrangler dev` itself. The CLI never imports `app.ts`: the database
901
- * is the D1 binding whose `migrations_dir` is `migrations/<database>` (planning/migrations.md).
904
+ * is the D1 binding whose `migrations_dir` is `migrations/<database>`.
902
905
  */
903
906
  const CONFIG_FILES = ["wrangler.jsonc", "wrangler.json"];
904
907
  function configPath(root) {
@@ -1,11 +1,11 @@
1
- import { t as Issue } from "../issues-Bxq4JExX.js";
1
+ import { t as Issue } from "../issues-CKto95IW.js";
2
2
  //#region src/client/types.d.ts
3
3
  /**
4
4
  * The types a generated client (`.isai/client.ts`) is written in: pages, list queries with the
5
5
  * lookups each kind takes, files as JSON, and the auth module's endpoints. Values are their JSON
6
6
  * forms: dates are ISO strings, money integer minor units, refs ids.
7
7
  */
8
- /** A list's page (planning/resources.md → Pagination); `page`, `pages` and `count` when asked. */
8
+ /** A list's page; `page`, `pages` and `count` when asked. */
9
9
  interface Page<T> {
10
10
  readonly data: T[];
11
11
  readonly next: string | null;
@@ -62,7 +62,7 @@ interface HistoryQuery {
62
62
  readonly after?: string;
63
63
  readonly before?: string;
64
64
  }
65
- /** One audit entry on a row (planning/audit.md → Reading). */
65
+ /** One audit entry on a row. */
66
66
  interface HistoryEntry {
67
67
  readonly id: string;
68
68
  readonly createdAt: string;
@@ -82,6 +82,12 @@ interface FileJSON {
82
82
  readonly type: string;
83
83
  readonly width?: number;
84
84
  readonly height?: number;
85
+ /** An image's smaller copies, widest first: `srcset(file)` lists them for an `<img>`. */
86
+ readonly sizes?: readonly {
87
+ readonly width: number;
88
+ readonly height: number;
89
+ readonly url: string;
90
+ }[];
85
91
  }
86
92
  /** `GET /api/auth/me`: the user, their roles, and their permission keys (to show or hide what the UI offers). */
87
93
  interface Me<U> {
@@ -151,8 +157,10 @@ interface ResourceEndpoint {
151
157
  /** `read`, `create`, `update`, `delete`, trait actions (`publish`, `reorder`), `history`. */
152
158
  readonly actions: readonly string[];
153
159
  readonly custom?: readonly string[];
154
- /** Its writable file fields: `upload(field, file)` sends a file to attach by id (planning/uploads.md). */
160
+ /** Its writable file fields: `upload(field, file)` sends a file to attach by id. */
155
161
  readonly uploads?: readonly string[];
162
+ /** Those of them that are images: resized in the browser before they're sent (`./resize`). */
163
+ readonly images?: readonly string[];
156
164
  }
157
165
  /** A route in the endpoint table. */
158
166
  interface RouteEndpoint {
@@ -181,7 +189,7 @@ export declare function listQuery(query: ListQuery<Readonly<Record<string, unkno
181
189
  //#endregion
182
190
  //#region src/client/errors.d.ts
183
191
  /**
184
- * A failed call: the API's one error shape (planning/requests.md → Errors),
192
+ * A failed call: the API's one error shape,
185
193
  * `{ error: { code, message, … }, requestId }`, with its status.
186
194
  */
187
195
  export declare class ApiError extends Error {
@@ -205,4 +213,37 @@ export declare class ApiError extends Error {
205
213
  get fields(): Readonly<Record<string, string>>;
206
214
  }
207
215
  //#endregion
216
+ //#region src/client/files.d.ts
217
+ /**
218
+ * An image's `srcset`: the image and its smaller copies, each with its width, so the browser loads
219
+ * the one the screen needs (`<img src={file.url} srcset={srcset(file)} sizes="50vw">`). Undefined
220
+ * for a file without copies: its `url` is its only size.
221
+ */
222
+ export declare function srcset(file: Pick<FileJSON, "url" | "width" | "sizes">): string | undefined;
223
+ /** The smallest copy at least `width` wide (or the widest there is), else the file itself: a preview's image. */
224
+ export declare function sizeFor(file: Pick<FileJSON, "url" | "width" | "height" | "sizes">, width: number): {
225
+ readonly url: string;
226
+ readonly width?: number | undefined;
227
+ readonly height?: number | undefined;
228
+ };
229
+ //#endregion
230
+ //#region src/client/resize.d.ts
231
+ /**
232
+ * Images are made smaller in the browser before they're uploaded: the full image at most 1080 px
233
+ * wide, and a copy at most 540 px wide (a list's card, a thumbnail, a link's preview), both kept
234
+ * when the image is larger than that; never made larger. Phones are the screens that matter: 1080
235
+ * is sharp across one at 3×, and the original (often 4000 px and several MB) isn't kept.
236
+ *
237
+ * Each is encoded again, which also drops the photo's metadata (a phone's GPS position): WebP where
238
+ * the browser can encode it, else JPEG; PNG only for an image with transparent pixels, which JPEG
239
+ * can't keep. No library: the browser decodes (turned the way the camera says), draws and encodes.
240
+ * A large step down is drawn in halves, which keeps it sharp. Where it can't (no `createImageBitmap`,
241
+ * or a format the browser can't decode), the file is uploaded as it is.
242
+ */
243
+ /** The widths an image is kept at, at most. */
244
+ export declare const IMAGE_WIDTHS: {
245
+ readonly full: 1080;
246
+ readonly small: 540;
247
+ };
248
+ //#endregion
208
249
  export type { AuthApi, ChoiceFilter, ClientBase, ClientOptions, Endpoints, FetchLike, FileJSON, FlagFilter, HistoryEntry, HistoryQuery, Invitation, InviteJSON, ListQuery, Me, Page, Person, RangeFilter, ResourceEndpoint, RouteEndpoint, SignInMethod, TextFilter };
@@ -1,7 +1,7 @@
1
1
  import { t as kebabCase } from "../case-BnpcvhdW.js";
2
2
  //#region src/client/errors.ts
3
3
  /**
4
- * A failed call: the API's one error shape (planning/requests.md → Errors),
4
+ * A failed call: the API's one error shape,
5
5
  * `{ error: { code, message, … }, requestId }`, with its status.
6
6
  */
7
7
  var ApiError = class extends Error {
@@ -33,15 +33,130 @@ async function errorOf(response) {
33
33
  return new ApiError(response.status, typeof code === "string" ? code : "HttpError", typeof message === "string" ? message : `The request failed (${response.status})`, Array.isArray(issues) ? issues : [], details, typeof body.requestId === "string" ? body.requestId : void 0);
34
34
  }
35
35
  //#endregion
36
+ //#region src/client/resize.ts
37
+ /**
38
+ * Images are made smaller in the browser before they're uploaded: the full image at most 1080 px
39
+ * wide, and a copy at most 540 px wide (a list's card, a thumbnail, a link's preview), both kept
40
+ * when the image is larger than that; never made larger. Phones are the screens that matter: 1080
41
+ * is sharp across one at 3×, and the original (often 4000 px and several MB) isn't kept.
42
+ *
43
+ * Each is encoded again, which also drops the photo's metadata (a phone's GPS position): WebP where
44
+ * the browser can encode it, else JPEG; PNG only for an image with transparent pixels, which JPEG
45
+ * can't keep. No library: the browser decodes (turned the way the camera says), draws and encodes.
46
+ * A large step down is drawn in halves, which keeps it sharp. Where it can't (no `createImageBitmap`,
47
+ * or a format the browser can't decode), the file is uploaded as it is.
48
+ */
49
+ /** The widths an image is kept at, at most. */
50
+ const IMAGE_WIDTHS = {
51
+ full: 1080,
52
+ small: 540
53
+ };
54
+ /** How tall an image may be, for each width: a very tall one is held to twice its width's limit. */
55
+ const TALLEST = 2;
56
+ /** JPEG and WebP quality: no visible loss in a photo, at a fraction of its size. */
57
+ const QUALITY = .82;
58
+ /** `width` × `height` made to fit within `max` wide (and twice that tall), never larger. */
59
+ function fitWithin(width, height, max) {
60
+ const scale = Math.min(1, max / width, max * TALLEST / height);
61
+ return {
62
+ width: Math.max(1, Math.round(width * scale)),
63
+ height: Math.max(1, Math.round(height * scale))
64
+ };
65
+ }
66
+ /** The sizes it's drawn at on the way down to `to`, halving each time, ending at `to`. */
67
+ function stepsDown(from, to) {
68
+ const steps = [];
69
+ let { width, height } = from;
70
+ while (width / 2 > to.width) {
71
+ width = Math.round(width / 2);
72
+ height = Math.round(height / 2);
73
+ steps.push({
74
+ width,
75
+ height
76
+ });
77
+ }
78
+ steps.push(to);
79
+ return steps;
80
+ }
81
+ /** The image's sizes, or undefined when the browser can't make them (it's uploaded as it is). */
82
+ async function resized(file, name) {
83
+ if (typeof createImageBitmap === "undefined") return void 0;
84
+ let bitmap;
85
+ try {
86
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
87
+ } catch {
88
+ return;
89
+ }
90
+ try {
91
+ const source = {
92
+ width: bitmap.width,
93
+ height: bitmap.height
94
+ };
95
+ const full = drawn(bitmap, source, fitWithin(source.width, source.height, IMAGE_WIDTHS.full));
96
+ const clear = file.type !== "image/jpeg" && hasTransparency(full);
97
+ const fullFile = await encoded(full, name, clear);
98
+ if (source.width <= IMAGE_WIDTHS.small) return { full: fullFile };
99
+ return {
100
+ full: fullFile,
101
+ small: await encoded(drawn(full, full, fitWithin(source.width, source.height, IMAGE_WIDTHS.small)), name, clear)
102
+ };
103
+ } finally {
104
+ bitmap.close();
105
+ }
106
+ }
107
+ function canvasOf(width, height) {
108
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
109
+ const canvas = document.createElement("canvas");
110
+ canvas.width = width;
111
+ canvas.height = height;
112
+ return canvas;
113
+ }
114
+ function context2d(canvas) {
115
+ const context = canvas.getContext("2d");
116
+ if (!context) throw new Error("isai: this browser can't draw images");
117
+ context.imageSmoothingEnabled = true;
118
+ context.imageSmoothingQuality = "high";
119
+ return context;
120
+ }
121
+ /** `image` drawn at `to`, halving from `from` on the way. */
122
+ function drawn(image, from, to) {
123
+ let current = image;
124
+ let canvas;
125
+ for (const step of stepsDown(from, to)) {
126
+ canvas = canvasOf(step.width, step.height);
127
+ context2d(canvas).drawImage(current, 0, 0, step.width, step.height);
128
+ current = canvas;
129
+ }
130
+ return canvas;
131
+ }
132
+ /** Whether any pixel isn't fully opaque. */
133
+ function hasTransparency(canvas) {
134
+ const { data } = context2d(canvas).getImageData(0, 0, canvas.width, canvas.height);
135
+ for (let at = 3; at < data.length; at += 4) if (data[at] < 255) return true;
136
+ return false;
137
+ }
138
+ const blobOf = (canvas, type) => "convertToBlob" in canvas ? canvas.convertToBlob({
139
+ type,
140
+ quality: QUALITY
141
+ }) : new Promise((resolve) => canvas.toBlob(resolve, type, QUALITY));
142
+ /** WebP where the browser encodes it (it answers PNG where it can't), else JPEG, or PNG to keep transparency. */
143
+ async function encoded(canvas, name, clear) {
144
+ let blob = await blobOf(canvas, "image/webp");
145
+ if (blob?.type !== "image/webp") blob = await blobOf(canvas, clear ? "image/png" : "image/jpeg");
146
+ if (!blob) throw new Error("isai: this browser couldn't encode the image");
147
+ const extension = blob.type === "image/webp" ? "webp" : blob.type === "image/png" ? "png" : "jpg";
148
+ return new File([blob], `${name.replace(/\.[^.]*$/, "") || "image"}.${extension}`, { type: blob.type });
149
+ }
150
+ //#endregion
36
151
  //#region src/client/client.ts
37
152
  /**
38
- * The client's runtime (planning/client.md): plain async calls over `fetch`, built from the endpoint
153
+ * The client's runtime: plain async calls over `fetch`, built from the endpoint
39
154
  * table a generated client (`.isai/client.ts`) hands it. It holds no cache: a frontend wraps the
40
155
  * calls in what it uses (TanStack Query, nanostores).
41
156
  *
42
157
  * resources api.<name>.list(query) · get(key) · create(input) · update(key, input) · delete(key)
43
158
  * · publish(key) · unpublish(key) · reorder(keys) · history(key, query)
44
- * · <custom action>(key, input?) · upload(field, file)
159
+ * · <custom action>(key, input?) · upload(field, file) (an image is resized first)
45
160
  * (a singleton's calls take no key)
46
161
  * routes api.<name>(args) (path params and input in one object)
47
162
  * auth api.auth.me() · signIn() · signOut() · users · invites
@@ -52,11 +167,12 @@ function createClient(endpoints, options = {}) {
52
167
  const base = (options.baseUrl ?? "/api").replace(/\/$/, "");
53
168
  const send = options.fetch ?? ((input, init) => fetch(input, init));
54
169
  const listeners = /* @__PURE__ */ new Set();
55
- async function call(method, path, { query, body, signedOutIsAnswer = false } = {}) {
170
+ async function call(method, path, { query, body, headers: extra, signedOutIsAnswer = false } = {}) {
56
171
  const search = query && query.size > 0 ? `?${query.toString().replace(/=(?=&|$)/g, "")}` : "";
57
172
  const headers = {
58
173
  Accept: "application/json",
59
- ...options.headers
174
+ ...options.headers,
175
+ ...extra
60
176
  };
61
177
  const file = body instanceof Blob ? body : void 0;
62
178
  if (file) {
@@ -122,13 +238,27 @@ function resourceCalls(call, endpoint) {
122
238
  for (const action of endpoint.custom ?? []) calls[action] = actionCall(call, keyed, action);
123
239
  if (endpoint.uploads?.length) {
124
240
  const fields = new Set(endpoint.uploads);
241
+ const images = new Set(endpoint.images);
125
242
  calls.upload = (field, file) => {
126
243
  if (!fields.has(field)) throw new TypeError(`isai: ${base} takes no upload for ${field}`);
127
- return call("POST", `/uploads${base}/${kebabCase(field)}`, { body: file });
244
+ const path = `/uploads${base}/${kebabCase(field)}`;
245
+ if (!images.has(field)) return call("POST", path, { body: file });
246
+ return uploadImage(call, path, file);
128
247
  };
129
248
  }
130
249
  return calls;
131
250
  }
251
+ /** An image, resized: the full one, then its smaller copy with `X-Size-Of`; the answer is the image. */
252
+ async function uploadImage(call, path, file) {
253
+ const sizes = await resized(file, file.name ?? "image");
254
+ if (!sizes) return call("POST", path, { body: file });
255
+ const image = await call("POST", path, { body: sizes.full });
256
+ if (!sizes.small) return image;
257
+ return call("POST", path, {
258
+ body: sizes.small,
259
+ headers: { "X-Size-Of": image.id }
260
+ });
261
+ }
132
262
  function actionCall(call, keyed, action) {
133
263
  return keyed((path, input) => call("POST", `${path}/${kebabCase(action)}`, input === void 0 ? {} : { body: input }));
134
264
  }
@@ -179,7 +309,7 @@ function pageQuery(query) {
179
309
  ...query.before === void 0 ? {} : { before: query.before }
180
310
  };
181
311
  }
182
- /** The auth module's endpoints (planning/auth.md → Endpoints). */
312
+ /** The auth module's endpoints. */
183
313
  function authApi(call, base) {
184
314
  const person = (id) => `/auth/users/${segment(id)}`;
185
315
  return {
@@ -211,4 +341,22 @@ function browser() {
211
341
  return { location };
212
342
  }
213
343
  //#endregion
214
- export { ApiError, createClient, listQuery };
344
+ //#region src/client/files.ts
345
+ /**
346
+ * An image's `srcset`: the image and its smaller copies, each with its width, so the browser loads
347
+ * the one the screen needs (`<img src={file.url} srcset={srcset(file)} sizes="50vw">`). Undefined
348
+ * for a file without copies: its `url` is its only size.
349
+ */
350
+ function srcset(file) {
351
+ if (!file.sizes?.length || !file.width) return void 0;
352
+ return [...file.sizes, {
353
+ url: file.url,
354
+ width: file.width
355
+ }].map((one) => `${one.url} ${one.width}w`).join(", ");
356
+ }
357
+ /** The smallest copy at least `width` wide (or the widest there is), else the file itself: a preview's image. */
358
+ function sizeFor(file, width) {
359
+ return [...file.sizes ?? []].sort((a, b) => a.width - b.width).find((one) => one.width >= width) ?? file;
360
+ }
361
+ //#endregion
362
+ export { ApiError, IMAGE_WIDTHS, createClient, listQuery, sizeFor, srcset };
@@ -1,6 +1,6 @@
1
- import { t as fromBase64url } from "./base64url-DwWPqoOT.js";
2
- import { n as PROVIDERS } from "./tables-C3Uza4qK.js";
3
- import { a as byRole, c as tenant, i as anyone, l as user, n as PolicyRegistry, o as owner, s as readsTenant } from "./policies-yVzFCjxU.js";
1
+ import { t as fromBase64url } from "./base64url-C5GTYIL5.js";
2
+ import { n as PROVIDERS } from "./tables-IrLjsomf.js";
3
+ import { a as byRole, c as tenant, i as anyone, l as user, n as PolicyRegistry, o as owner, s as readsTenant } from "./policies-CKIdBFKt.js";
4
4
  //#region src/auth/create-auth.ts
5
5
  const methods = { oauth: (provider, options) => ({
6
6
  kind: "oauth",
@@ -15,7 +15,7 @@ const authPerms = {
15
15
  invite: "auth.invite",
16
16
  /** See the people with accounts, give and take their roles, suspend them. */
17
17
  manageUsers: "auth.manageUsers",
18
- /** Read the whole audit log (`GET /api/audit`), with the log on (planning/audit.md). */
18
+ /** Read the whole audit log (`GET /api/audit`), with the log on. */
19
19
  readAudit: "audit.read"
20
20
  };
21
21
  var Auth = class {
@@ -31,7 +31,7 @@ var Auth = class {
31
31
  /**
32
32
  * Gives `email` a role from `app.ts`, from code (a tenant's setup service, a seed): now, if it has
33
33
  * an account in the current database, else when it first signs in. Safe to repeat.
34
- * `held: "platform"` puts the assignment beyond the tenant's reach (planning/tenancy.md → End of v1).
34
+ * `held: "platform"` puts the assignment beyond the tenant's reach.
35
35
  */
36
36
  invite(input) {
37
37
  if (!this.invites) return Promise.reject(/* @__PURE__ */ new Error("isai: auth.invite is used before the runtime started the app"));