@isaijs/core 0.1.5 → 0.1.8

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 (30) hide show
  1. package/dist/adapters/storage/r2/index.d.ts +3 -0
  2. package/dist/adapters/storage/r2/index.js +13 -2
  3. package/dist/auth/index.d.ts +2 -2
  4. package/dist/auth/index.js +2 -2
  5. package/dist/cli/index.js +79 -11
  6. package/dist/client/index.d.ts +4 -0
  7. package/dist/client/index.js +18 -13
  8. package/dist/{create-auth-BHdX68Ng.js → create-auth-BCvMwlVD.js} +18 -10
  9. package/dist/{data-layer-C5oJZ8V1.js → data-layer-CVCFjPyv.js} +15 -10
  10. package/dist/{data-layer-43pme0Gy.d.ts → data-layer-nvm_ivGQ.d.ts} +6 -2
  11. package/dist/db/index.d.ts +2 -2
  12. package/dist/db/index.js +5 -5
  13. package/dist/{index-D0FEnMhn.d.ts → index-Bb9Px6SR.d.ts} +116 -22
  14. package/dist/{index-CYcklQ1l.d.ts → index-CaR3hb0i.d.ts} +5 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +169 -79
  17. package/dist/{make-ByA-84h8.js → make-jMJABOWI.js} +21 -3
  18. package/dist/{makers-P9kg68C8.js → makers-A0Pj6Tda.js} +3 -3
  19. package/dist/{migrator-B25prc9n.js → migrator-DxTpns_-.js} +174 -30
  20. package/dist/{model-Dc3HunKb.js → model-6pJBqqGd.js} +31 -1
  21. package/dist/{policies-KsqmlX67.js → policies-C_oiSgmY.js} +1 -1
  22. package/dist/{resource-BQK7bkPQ.js → resource-BIMAiOre.js} +8 -6
  23. package/dist/{tables-DgSwge7s.js → tables-DjW96Ubl.js} +1 -1
  24. package/dist/testing/index.d.ts +1 -1
  25. package/dist/testing/index.js +4 -4
  26. package/dist/{types-BgOkJy5d.d.ts → types-D5SIVeej.d.ts} +59 -2
  27. package/dist/validation/index.d.ts +2 -2
  28. package/dist/validation/index.js +1 -1
  29. package/dist/{validation-Cppmd0RV.js → validation-C3MCRBQO.js} +26 -3
  30. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
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 { 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";
1
+ import { U as toKind, V as object, W as validate, _ as line, c as fileTypeNames, h as email, q as ValidationError } from "./validation-C3MCRBQO.js";
2
+ import { A as planUpdate, B as checkTenancy, C as database, D as recordAction, F as modelOfRow, H as evaluate, N as conditionOf, O as withTableTotals, P as withIncludeRule, R as PLATFORM, S as clockOf, T as driverOf, V as NeedsDatabase, W as systemClock, Y as NotFound, Z as UniqueFailed, j as keyCondition, k as planInsert, r as sync, t as migrate, w as decodeStored, z as TENANT } from "./migrator-DxTpns_-.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-BIMAiOre.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-6pJBqqGd.js";
5
5
  import { r as ulid, t as kebabCase } from "./case-DpXlJCL9.js";
6
6
  import { n as notImplemented, t as NotImplementedError } from "./errors-mCNLlXg9.js";
7
- import { t as PLATFORM_PREFIX } from "./tables-DgSwge7s.js";
7
+ import { t as PLATFORM_PREFIX } from "./tables-DjW96Ubl.js";
8
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";
9
+ import { d as afterUpdate, f as bindFor, h as ownerFieldOf, m as isFalse, p as check, u as FALSE } from "./policies-C_oiSgmY.js";
10
+ import { i as checkRoles, n as authPerms, r as checkAuth } from "./create-auth-BCvMwlVD.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-A0Pj6Tda.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";
@@ -503,18 +503,6 @@ function accessOf(db) {
503
503
  return access;
504
504
  }
505
505
  const asCondition = (node) => () => new Condition(node);
506
- /** The paths a chain's `.include()` calls loaded. */
507
- const includesOf$1 = (steps) => steps.flatMap(([method, args]) => method === "include" ? args : []);
508
- /** The rows in a query's result: a list, a page, or one row (`first()`); none in a count. */
509
- function rowsIn(result) {
510
- if (Array.isArray(result)) return result;
511
- if (result && typeof result === "object") {
512
- const rows = result.rows;
513
- if (Array.isArray(rows)) return rows;
514
- if (modelOfRow(result)) return [result];
515
- }
516
- return [];
517
- }
518
506
  /** The route `db` and `can` for one request. */
519
507
  function boundary(options) {
520
508
  const { db, policies, asker } = options;
@@ -543,7 +531,7 @@ function boundary(options) {
543
531
  const readable = async (model, key, steps) => {
544
532
  const read = await filter(model, "read");
545
533
  if (!read) return void 0;
546
- const query = db.findMany(model).where(asCondition(keyCondition(model.spec, key))).where(asCondition(read));
534
+ const query = db.findMany(model)[withIncludeRule](includeRule(model)).where(asCondition(keyCondition(model.spec, key))).where(asCondition(read));
547
535
  return replay(query, steps.filter(([method]) => method === "include")).first();
548
536
  };
549
537
  /** Whether the row with this id meets the action's condition. */
@@ -569,48 +557,20 @@ function boundary(options) {
569
557
  };
570
558
  };
571
559
  /**
572
- * Related rows loaded with `.include()` keep only what their own model's `read` rule allows: a
573
- * ref the user can't read becomes `null`, a list keeps its readable rows. Level by level, one
574
- * query per model and level, for every row at once.
560
+ * Related rows loaded with `.include()` keep only what their own model's `read` rule allows: the
561
+ * rule is in each level's query (`db/orm/include.ts`), so a ref the user can't read is `null` and
562
+ * a list keeps its readable rows, with nothing read twice. One verdict per model, per query.
575
563
  */
576
- const underTheirPolicies = async (registry, rows, paths) => {
577
- const byFirst = /* @__PURE__ */ new Map();
578
- for (const path of paths) {
579
- const [first, ...rest] = path.split(".");
580
- if (first === void 0) continue;
581
- byFirst.set(first, [...byFirst.get(first) ?? [], ...rest.length > 0 ? [rest.join(".")] : []]);
582
- }
583
- for (const [field, deeper] of byFirst) {
584
- const holders = rows;
585
- const related = holders.flatMap((row) => {
586
- const value = row[field];
587
- return Array.isArray(value) ? value : value && typeof value === "object" ? [value] : [];
588
- });
589
- if (related.length === 0) continue;
590
- const spec = modelOfRow(related[0]);
591
- if (!spec) continue;
592
- const target = new Model(spec, registry);
593
- const read = await readableIds(target, related.map((r) => r.id));
594
- for (const row of holders) {
595
- const value = row[field];
596
- if (Array.isArray(value)) row[field] = value.filter((r) => read.has(r.id));
597
- else if (value && typeof value === "object") {
598
- if (!read.has(value.id)) row[field] = null;
599
- }
564
+ const includeRule = (model) => {
565
+ const verdicts = /* @__PURE__ */ new Map();
566
+ return (spec) => {
567
+ let found = verdicts.get(spec.name);
568
+ if (!found) {
569
+ found = verdict(new Model(spec, model.registry), "read").then((v) => v.kind === "denied" ? false : v.node);
570
+ verdicts.set(spec.name, found);
600
571
  }
601
- const kept = holders.flatMap((row) => {
602
- const value = row[field];
603
- return Array.isArray(value) ? value : value && typeof value === "object" ? [value] : [];
604
- });
605
- if (deeper.length > 0) await underTheirPolicies(registry, kept, deeper);
606
- }
607
- };
608
- /** The ids, among these, of rows of `target` its `read` rule lets this request see. */
609
- const readableIds = async (target, ids) => {
610
- const verdictOf = await verdict(target, "read");
611
- if (verdictOf.kind === "denied") return /* @__PURE__ */ new Set();
612
- const found = await db.findMany(target).where((r) => r.id.in([...new Set(ids)])).where(asCondition(verdictOf.node));
613
- return new Set(found.map((r) => r.id));
572
+ return found;
573
+ };
614
574
  };
615
575
  /**
616
576
  * The plan with the placeholders in its own `where`s bound (`byRole`, `user`, `tenant`), as a
@@ -657,9 +617,7 @@ function boundary(options) {
657
617
  t: "or",
658
618
  items: []
659
619
  };
660
- const result = await replay(db.findMany(model).where(asCondition(node)), plan.steps);
661
- await underTheirPolicies(model.registry, rowsIn(result), includesOf$1(plan.steps));
662
- return result;
620
+ return replay(db.findMany(model)[withIncludeRule](includeRule(model)).where(asCondition(node)), plan.steps);
663
621
  }
664
622
  case "updateMany":
665
623
  case "deleteMany": {
@@ -674,7 +632,6 @@ function boundary(options) {
674
632
  case "findOne": {
675
633
  const key = plan.args[0];
676
634
  const row = await readable(model, key, plan.steps);
677
- if (row !== void 0) await underTheirPolicies(model.registry, [row], includesOf$1(plan.steps));
678
635
  if (row !== void 0 && action !== "read") {
679
636
  const node = required(await verdict(model, action));
680
637
  if (!await holdsOn(model, row.id, node)) throw deny();
@@ -1692,6 +1649,53 @@ const engine = (resource, action, context, files) => {
1692
1649
  }
1693
1650
  };
1694
1651
  //#endregion
1652
+ //#region src/tasks/schedule.ts
1653
+ var Schedule = class {
1654
+ when;
1655
+ run;
1656
+ constructor(when, run) {
1657
+ this.when = when;
1658
+ this.run = run;
1659
+ }
1660
+ };
1661
+ const DAILY = /^([01]\d|2[0-3]):00$/;
1662
+ /** `schedule(when, run)` in `#app`. */
1663
+ function schedule(when, run) {
1664
+ if ("daily" in when && !DAILY.test(when.daily)) throw new TypeError(`isai: a daily schedule runs at a whole hour ("03:00"), not "${when.daily}"`);
1665
+ if (!("daily" in when) && when.hourly !== true) throw new TypeError("isai: a schedule is { hourly: true } or { daily: \"HH:00\" }");
1666
+ return new Schedule(when, run);
1667
+ }
1668
+ /** Every schedule among the app modules' exports. */
1669
+ function schedulesIn(modules) {
1670
+ const found = /* @__PURE__ */ new Set();
1671
+ for (const module of modules) for (const value of Object.values(module)) if (value instanceof Schedule) found.add(value);
1672
+ return [...found];
1673
+ }
1674
+ /** The hour (0–23) `now` falls in, in `timezone`. */
1675
+ function hourIn(now, timezone = "UTC") {
1676
+ const hour = new Intl.DateTimeFormat("en-GB", {
1677
+ timeZone: timezone,
1678
+ hour: "2-digit",
1679
+ hourCycle: "h23"
1680
+ }).format(now);
1681
+ return Number(hour);
1682
+ }
1683
+ /** Whether `when` is due in the hour `now` falls in. */
1684
+ function isDue(when, now, timezone) {
1685
+ return "daily" in when ? Number(when.daily.slice(0, 2)) === hourIn(now, timezone) : true;
1686
+ }
1687
+ /**
1688
+ * The timer check: runs every schedule due in `now`'s hour, one after another. One that fails is
1689
+ * logged and the rest still run.
1690
+ */
1691
+ async function tick(schedules, now, timezone) {
1692
+ for (const due of schedules.filter((one) => isDue(one.when, now, timezone))) try {
1693
+ await due.run({ now });
1694
+ } catch (error) {
1695
+ console.error(`isai: a schedule (${JSON.stringify(due.when)}) failed`, error);
1696
+ }
1697
+ }
1698
+ //#endregion
1695
1699
  //#region src/audit/routes.ts
1696
1700
  /**
1697
1701
  * The whole log, a built-in route:
@@ -2393,6 +2397,21 @@ async function invite(db, models, input) {
2393
2397
  }
2394
2398
  });
2395
2399
  }
2400
+ /**
2401
+ * `auth.revoke` from code, undoing `auth.invite`: the email's invite to the role is deleted, and if
2402
+ * it has an account here, the role is taken away (held by the platform or not). Its sessions stay,
2403
+ * with what its other roles allow. Safe to repeat. No permission or last-owner check: services are
2404
+ * trusted, so give the role to someone else first.
2405
+ */
2406
+ async function revoke(db, models, input) {
2407
+ const { User, Role, UserRole, Invite } = models;
2408
+ const role = await db.findOne(Role, { key: input.role });
2409
+ if (!role) throw new TypeError(`isai: auth.revoke takes the role "${input.role}", which app.ts's roles don't have`);
2410
+ const invite = await db.findOne(Invite, { email: input.email });
2411
+ if (invite?.role === role.id) await db.deleteOne(Invite, { id: invite.id });
2412
+ const user = await db.findOne(User, { email: input.email });
2413
+ if (user) await db.deleteMany(UserRole).where((h) => h.user.is(user.id)).where((h) => h.role.is(role.id));
2414
+ }
2396
2415
  //#endregion
2397
2416
  //#region src/auth/routes.ts
2398
2417
  /**
@@ -2478,6 +2497,7 @@ function uploadTargets(modules, resources) {
2478
2497
  route,
2479
2498
  key
2480
2499
  },
2500
+ ...route.upload ? { check: route.upload } : {},
2481
2501
  allow: async (context) => {
2482
2502
  if (!route.anonymous && !context.user) throw signInRequired();
2483
2503
  }
@@ -2705,6 +2725,20 @@ function uploadRoute(options) {
2705
2725
  throw fileTypeRefused(fileTypeNames(spec.types));
2706
2726
  }
2707
2727
  const db = accessOf(context.db).trusted;
2728
+ if (target.check) {
2729
+ const { bytes } = await db.findMany(options.upload).totals((u) => ({ bytes: u.size.sum() }));
2730
+ try {
2731
+ await target.check(context, {
2732
+ field: target.route?.key ?? field,
2733
+ size,
2734
+ type: sniffed.type,
2735
+ pending: bytes ?? 0
2736
+ });
2737
+ } catch (error) {
2738
+ await body.cancel().catch(() => {});
2739
+ throw error;
2740
+ }
2741
+ }
2708
2742
  const user = context.user;
2709
2743
  const put = async (key) => {
2710
2744
  try {
@@ -2742,7 +2776,10 @@ function uploadRoute(options) {
2742
2776
  height
2743
2777
  }].sort((a, b) => b.width - a.width)
2744
2778
  };
2745
- await db.updateOne(options.upload, { fileId: sizeOf }, { file });
2779
+ await db.updateOne(options.upload, { fileId: sizeOf }, {
2780
+ file,
2781
+ size: Number(pending.size ?? 0) + size
2782
+ });
2746
2783
  return json(201, await options.answer(file, new URL(request.url).origin));
2747
2784
  }
2748
2785
  const id = newFileId();
@@ -2759,6 +2796,7 @@ function uploadRoute(options) {
2759
2796
  await db.createOne(options.upload, {
2760
2797
  fileId: id,
2761
2798
  file,
2799
+ size,
2762
2800
  target: name,
2763
2801
  user: user?.id ?? null,
2764
2802
  expiresAt: new Date(clockOf(db).now().getTime() + UPLOAD_TTL_MS)
@@ -2868,8 +2906,10 @@ function dispositionOf(type, name) {
2868
2906
  }
2869
2907
  //#endregion
2870
2908
  //#region src/files/service.ts
2909
+ /** How many expired uploads one sweep reads at a time. */
2910
+ const SWEEP_BATCH = 100;
2871
2911
  function appFiles(options) {
2872
- const { storages, url, tenant } = options;
2912
+ const { storages, url, tenant, db: dbOf, upload: uploadOf } = options;
2873
2913
  const storageOf = (name) => {
2874
2914
  const storage = storages[name];
2875
2915
  if (!storage) throw new TypeError(`isai: there's no storage "${name}" in app.ts`);
@@ -2929,6 +2969,30 @@ function appFiles(options) {
2929
2969
  url: async (file, { origin = "" } = {}) => url(file, origin),
2930
2970
  delete: async (file) => {
2931
2971
  await Promise.all(keysOf(file).map((key) => storageOf(file.storage).delete(key)));
2972
+ },
2973
+ sweep: async () => {
2974
+ const upload = uploadOf();
2975
+ if (!upload) throw new TypeError("isai: app.files.sweep needs base.uploads({ database }) in db/index.ts");
2976
+ const db = dbOf();
2977
+ const now = clockOf(db).now();
2978
+ let removed = 0;
2979
+ for (;;) {
2980
+ const expired = await db.findMany(upload).where((u) => u.expiresAt.lte(now)).limit(SWEEP_BATCH).all();
2981
+ if (expired.length === 0) return { removed };
2982
+ await Promise.all(expired.flatMap((row) => {
2983
+ const file = row.file;
2984
+ return keysOf(file).map(async (key) => {
2985
+ try {
2986
+ await storageOf(file.storage).delete(key);
2987
+ } catch (error) {
2988
+ console.error(`isai: couldn't remove ${key} from storage "${file.storage}"`, error);
2989
+ }
2990
+ });
2991
+ }));
2992
+ const ids = expired.map((row) => String(row.fileId));
2993
+ await db.deleteMany(upload).where((u) => u.fileId.in(ids));
2994
+ removed += ids.length;
2995
+ }
2932
2996
  }
2933
2997
  };
2934
2998
  }
@@ -3139,13 +3203,32 @@ function createCore(config, env, options = {}) {
3139
3203
  checkRoles(auth, config.tenancy.roles, "tenancy.roles");
3140
3204
  }
3141
3205
  const models = auth ? authModels(auth.user) : void 0;
3142
- if (models) config.auth?.attach((input) => invite(currentDb(), models, input));
3143
- const single = named.kind === "database" ? database({
3206
+ if (models) config.auth?.attach({
3207
+ invite: (input) => invite(currentDb(), models, input),
3208
+ revoke: (input) => revoke(currentDb(), models, input)
3209
+ });
3210
+ const scope = new Scope();
3211
+ /** Tells `onWrite` what each committed write on `db` touched, and in which tenant. */
3212
+ const watched = (db, name) => {
3213
+ const { onWrite } = config;
3214
+ if (onWrite) db.subscribe(async (writes) => {
3215
+ const tenant = scope.current()?.tenant;
3216
+ try {
3217
+ await onWrite(writes, {
3218
+ database: name,
3219
+ ...tenant ? { tenant } : {}
3220
+ });
3221
+ } catch (error) {
3222
+ console.error(`isai: onWrite failed after a write to the ${name} database`, error);
3223
+ }
3224
+ });
3225
+ return db;
3226
+ };
3227
+ const single = named.kind === "database" ? watched(database({
3144
3228
  driver: named,
3145
3229
  name,
3146
3230
  ...zoned
3147
- }) : void 0;
3148
- const scope = new Scope();
3231
+ }), name) : void 0;
3149
3232
  const registry = serviceRegistry(services, seeds);
3150
3233
  /** The app's database: with tenancy, the current tenant's (inside one), and nothing outside. */
3151
3234
  const currentDb = () => {
@@ -3247,11 +3330,11 @@ function createCore(config, env, options = {}) {
3247
3330
  });
3248
3331
  return pipeline;
3249
3332
  };
3250
- const platform = named.kind === "tenantDatabases" ? database({
3333
+ const platform = named.kind === "tenantDatabases" ? watched(database({
3251
3334
  driver: config.databases[PLATFORM],
3252
3335
  name: PLATFORM,
3253
3336
  ...zoned
3254
- }) : void 0;
3337
+ }), PLATFORM) : void 0;
3255
3338
  const platformModels = auth && platform && config.tenancy ? authModels(new Model(config.tenancy.tenant.registry.get(`${PLATFORM_PREFIX}User`), config.tenancy.tenant.registry), PLATFORM_PREFIX) : void 0;
3256
3339
  const platformDb = () => platform;
3257
3340
  let platformReady;
@@ -3323,6 +3406,7 @@ function createCore(config, env, options = {}) {
3323
3406
  registry,
3324
3407
  models,
3325
3408
  woken: syncedRoles,
3409
+ watched: (db) => watched(db, TENANT),
3326
3410
  run: (db, tenant, fn) => scope.run({
3327
3411
  db,
3328
3412
  tenant
@@ -3358,7 +3442,12 @@ function createCore(config, env, options = {}) {
3358
3442
  files: appFiles({
3359
3443
  storages,
3360
3444
  url,
3361
- tenant: () => scope.current()?.tenant
3445
+ tenant: () => scope.current()?.tenant,
3446
+ db: currentDb,
3447
+ upload: () => {
3448
+ const models = auth?.user.registry ?? config.tenancy?.tenant.registry;
3449
+ return models?.has("Upload") ? new Model(models.get("Upload"), models) : void 0;
3450
+ }
3362
3451
  }),
3363
3452
  handle,
3364
3453
  openTenant,
@@ -3393,7 +3482,7 @@ function createCore(config, env, options = {}) {
3393
3482
  return await named.call(id, ref, args, tenant);
3394
3483
  } }),
3395
3484
  runTask: async () => notImplemented("app.runTask"),
3396
- tick: async () => notImplemented("app.tick")
3485
+ tick: async (now) => tick(schedulesIn((await loadModules()).modules), now, timezone)
3397
3486
  };
3398
3487
  }
3399
3488
  /**
@@ -3404,11 +3493,11 @@ function createCore(config, env, options = {}) {
3404
3493
  */
3405
3494
  function tenantCore(databases, host, options) {
3406
3495
  const driver = databases.open(host);
3407
- const db = database({
3496
+ const db = options.watched(database({
3408
3497
  driver,
3409
3498
  name: TENANT,
3410
3499
  ...options.zoned
3411
- });
3500
+ }));
3412
3501
  let woken;
3413
3502
  let recorded = false;
3414
3503
  const wake = () => {
@@ -3569,8 +3658,9 @@ function createApp(runtime, configure, options = {}) {
3569
3658
  Tenant: runtime.tenant?.(start),
3570
3659
  route,
3571
3660
  resource,
3572
- fail
3661
+ fail,
3662
+ schedule
3573
3663
  };
3574
3664
  }
3575
3665
  //#endregion
3576
- export { NotImplementedError, Rejected, Route, createApp, fail };
3666
+ export { NotImplementedError, Rejected, Route, Schedule, createApp, fail };
@@ -1,4 +1,4 @@
1
- import { U as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-B25prc9n.js";
1
+ import { W as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-DxTpns_-.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) {
@@ -88,10 +88,12 @@ function cleanupMigration(options) {
88
88
  * tighten one, or add a check a check data step: passes when no row breaks it, else a `fix`
89
89
  * add a unique a resolution step before the index (duplicates as chosen)
90
90
  * remove a unique retired until cleanup (an earlier release may rely on it)
91
+ * change a ref's onDelete or a rebuild: the table and every table referencing it, copied
92
+ * table, or a table's storage whole in one step (`operations.ts` → rebuild)
91
93
  *
92
94
  * Refused, each with the safe sequence: an incompatible kind change (or toggling `secret`), a field
93
95
  * 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.
96
+ * reusing a retired name, a rebuild of tables that reference each other.
95
97
  */
96
98
  const pad = (n) => String(n).padStart(4, "0");
97
99
  const json = (value) => JSON.stringify(value);
@@ -299,13 +301,17 @@ function describeOperation(operation, tables) {
299
301
  what: `add ${tables[operation.table]?.model ?? operation.table}.${operation.column.field}`,
300
302
  how: "ADD COLUMN"
301
303
  };
304
+ case "rebuild": return {
305
+ what: `rebuild ${operation.tables.map((table) => table.model).join(", ")}`,
306
+ how: "copies every row"
307
+ };
302
308
  default: return {
303
309
  what: operation.op,
304
310
  how: ""
305
311
  };
306
312
  }
307
313
  }
308
- const slugOf = (operation) => operation.op === "createTable" ? `create_${operation.table.name}` : operation.op === "addColumn" ? `add_${operation.table}_${operation.column.name}` : operation.op;
314
+ const slugOf = (operation) => operation.op === "createTable" ? `create_${operation.table.name}` : operation.op === "addColumn" ? `add_${operation.table}_${operation.column.name}` : operation.op === "rebuild" ? `rebuild_${operation.tables[0]?.name ?? "tables"}` : operation.op;
309
315
  /** A template literal holding SQL (no backtick or `${` is left live). */
310
316
  const literal = (text) => `\`${text.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")}\``;
311
317
  function structureSource(name, after, statements, day, framework) {
@@ -482,6 +488,18 @@ function makeMigrations(options) {
482
488
  /** The structure operations between two snapshots, for naming the step and the plan. */
483
489
  function snapshotOperations(previous, next) {
484
490
  const operations = [];
491
+ const rebuilt = Object.values(next.tables).filter((table) => {
492
+ const before = previous.tables[table.name];
493
+ if (!before) return false;
494
+ const references = new Map(before.columns.map((c) => [c.name, JSON.stringify(c.references)]));
495
+ return Boolean(before.withoutRowid) !== Boolean(table.withoutRowid) || table.columns.some((c) => references.has(c.name) && references.get(c.name) !== JSON.stringify(c.references));
496
+ });
497
+ if (rebuilt.length > 0) operations.push({
498
+ op: "rebuild",
499
+ tables: rebuilt,
500
+ copy: {},
501
+ triggers: []
502
+ });
485
503
  for (const table of Object.values(next.tables)) {
486
504
  const before = previous.tables[table.name];
487
505
  if (!before) {
@@ -1,6 +1,6 @@
1
- import { K as Fake, q as fakeValue } from "./validation-Cppmd0RV.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";
1
+ import { J as Fake, Y as fakeValue } from "./validation-C3MCRBQO.js";
2
+ import { D as recordAction, E as forUpkeep, S as clockOf, k as planInsert, q as CheckFailed } from "./migrator-DxTpns_-.js";
3
+ import { I as fromBase64url, L as toBase64url, l as IMPLICIT_FIELDS, t as Model } from "./model-6pJBqqGd.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. */