velocious 1.0.641 → 1.0.643

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 (60) hide show
  1. package/README.md +25 -2
  2. package/build/authorization/base-resource.js +18 -5
  3. package/build/background-jobs/main.js +21 -6
  4. package/build/background-jobs/store.js +116 -7
  5. package/build/configuration-types.js +34 -2
  6. package/build/database/record/index.js +42 -11
  7. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +17 -3
  8. package/build/frontend-model-controller.js +10 -5
  9. package/build/frontend-model-resource/base-resource.js +95 -41
  10. package/build/frontend-models/base.js +1 -1
  11. package/build/src/authorization/base-resource.d.ts +19 -6
  12. package/build/src/authorization/base-resource.d.ts.map +1 -1
  13. package/build/src/authorization/base-resource.js +16 -6
  14. package/build/src/background-jobs/main.d.ts +3 -2
  15. package/build/src/background-jobs/main.d.ts.map +1 -1
  16. package/build/src/background-jobs/main.js +26 -7
  17. package/build/src/background-jobs/store.d.ts +16 -0
  18. package/build/src/background-jobs/store.d.ts.map +1 -1
  19. package/build/src/background-jobs/store.js +103 -7
  20. package/build/src/configuration-types.d.ts +72 -5
  21. package/build/src/configuration-types.d.ts.map +1 -1
  22. package/build/src/configuration-types.js +31 -3
  23. package/build/src/database/record/index.d.ts +44 -46
  24. package/build/src/database/record/index.d.ts.map +1 -1
  25. package/build/src/database/record/index.js +39 -12
  26. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  27. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +19 -4
  28. package/build/src/frontend-model-controller.d.ts +1 -1
  29. package/build/src/frontend-model-controller.d.ts.map +1 -1
  30. package/build/src/frontend-model-controller.js +9 -6
  31. package/build/src/frontend-model-resource/base-resource.d.ts +66 -32
  32. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  33. package/build/src/frontend-model-resource/base-resource.js +89 -42
  34. package/build/src/frontend-models/base.d.ts +1 -0
  35. package/build/src/frontend-models/base.d.ts.map +1 -1
  36. package/build/src/frontend-models/base.js +2 -2
  37. package/build/src/sync/sync-api-controller.d.ts.map +1 -1
  38. package/build/src/sync/sync-api-controller.js +6 -5
  39. package/build/src/sync/sync-envelope-replay-service.d.ts +1 -1
  40. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  41. package/build/src/sync/sync-envelope-replay-service.js +3 -2
  42. package/build/src/sync/sync-resource-base.d.ts +2 -0
  43. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  44. package/build/src/sync/sync-resource-base.js +3 -1
  45. package/build/sync/sync-api-controller.js +5 -5
  46. package/build/sync/sync-envelope-replay-service.js +2 -1
  47. package/build/sync/sync-resource-base.js +3 -0
  48. package/package.json +1 -1
  49. package/src/authorization/base-resource.js +18 -5
  50. package/src/background-jobs/main.js +21 -6
  51. package/src/background-jobs/store.js +116 -7
  52. package/src/configuration-types.js +34 -2
  53. package/src/database/record/index.js +42 -11
  54. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +17 -3
  55. package/src/frontend-model-controller.js +10 -5
  56. package/src/frontend-model-resource/base-resource.js +95 -41
  57. package/src/frontend-models/base.js +1 -1
  58. package/src/sync/sync-api-controller.js +5 -5
  59. package/src/sync/sync-envelope-replay-service.js +2 -1
  60. package/src/sync/sync-resource-base.js +3 -0
package/README.md CHANGED
@@ -47,7 +47,7 @@
47
47
  * AbortSignal-driven MySQL/MariaDB query cancellation for raw, model, and cross-tenant aggregate queries (see [docs/database-query-cancellation.md](docs/database-query-cancellation.md))
48
48
  * Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
49
49
  * Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
50
- * Backend record attachments with filesystem, S3, native callback, and bounded Node path-input persistence (see [docs/attachments.md](docs/attachments.md))
50
+ * Backend record attachments with filesystem, S3, native callback, bounded Node path-input persistence, and model-declared client sync policy (see [docs/attachments.md](docs/attachments.md))
51
51
 
52
52
  # Setup
53
53
 
@@ -701,6 +701,13 @@ For backend models, you can declare attachment helpers directly:
701
701
  Task.hasManyAttachments("files")
702
702
  Task.hasOneAttachment("descriptionFile")
703
703
  Task.hasOneAttachment("archivedPdf", {driver: "s3"})
704
+ User.hasOneAttachment("profilePicture", {
705
+ sync: {
706
+ fetch: "eager",
707
+ offlineRequirement: "optional",
708
+ retention: "evictable"
709
+ }
710
+ })
704
711
  ```
705
712
 
706
713
  `db:migrate` provisions the framework-owned attachment table before runtime
@@ -815,7 +822,9 @@ behavior. See
815
822
  [docs/attachments.md](docs/attachments.md#normalized-storage-driver-input) for
816
823
  the normalized input passed to custom drivers.
817
824
 
818
- For frontend models, configure `resourceConfig().attachments` and use:
825
+ For a resource with a backing model, the model attachment declaration
826
+ automatically generates `resourceConfig().attachments`; do not repeat it on the
827
+ resource. Use the generated attachment handles normally:
819
828
 
820
829
  ```js
821
830
  await frontendTask.update({descriptionFile: file})
@@ -828,6 +837,11 @@ await frontendTask.attach(file)
828
837
 
829
838
  Frontend model attachment input does not support `{path: ...}`.
830
839
  Use `File`/`Blob`/bytes/`contentBase64` payloads instead.
840
+ The optional model-level `sync` block is client-safe policy metadata for asset
841
+ cache adapters. It distinguishes eager/on-demand fetching,
842
+ durable/evictable retention, and optional/required offline availability.
843
+ Required offline assets must be durable. Backend driver configuration never
844
+ appears in generated frontend models or API manifests.
831
845
  Attachment metadata is exposed through the built-in `VelociousAttachment` frontend model with safe fields only: `id`, `recordType`, `recordId`, `name`, `position`, `filename`, `contentType`, `byteSize`, `createdAt`, and `updatedAt`. Storage internals such as `driver`, `storageKey`, and `contentBase64` remain hidden and non-queryable. Direct metadata queries require owner filters: `recordType`, `recordId`, and `name`.
832
846
 
833
847
  When your frontend app calls a backend on another host/port (or under a path prefix), configure transport once:
@@ -2393,6 +2407,15 @@ supervisor that preserves old generation units and release pins, and a deploy
2393
2407
  coordinator that retires the old generation before activating the healthy
2394
2408
  candidate without waiting for retired work to finish.
2395
2409
 
2410
+ Candidate activation performs bounded durable concurrency reconciliation: it
2411
+ examines queue-derived keys and counters that are active or stale instead of
2412
+ running a job-table count query for every historical key. If recovery retires a
2413
+ candidate while that work is still in flight, the retirement fence wins and
2414
+ activation cannot later restore ownership or acknowledge success. The SQL store
2415
+ also repairs secondary indexes missed by older background-job add-column
2416
+ upgrades through a one-time internal migration, with conflict-safe SQLite index
2417
+ creation across generation processes.
2418
+
2396
2419
  Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options, or by deriving the key in a hydrated job instance's non-static `concurrencyKey()` method. Explicit enqueue options win. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
2397
2420
 
2398
2421
  Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata. Process-level pooled-runner failures also carry one shared `context.runnerFailure` snapshot for every affected job, with active handoff identities, runner/worker lifecycle and PIDs, exit code/signal, termination reason, and an explicit nullable OOM verdict. Listen for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
@@ -1,10 +1,15 @@
1
1
  // @ts-check
2
2
 
3
+ /**
4
+ * Model class supported by authorization and shared frontend-model resources.
5
+ * @typedef {{new (): import("../database/record/index.js").default | import("../frontend-models/base.js").default, getModelName: () => string}} AuthorizationResourceModelClass
6
+ */
7
+
3
8
  /** Base class for authorization resources defining abilities for a model. */
4
9
  export default class AuthorizationBaseResource {
5
10
  /**
6
11
  * Model class.
7
- * @type {typeof import("../database/record/index.js").default | undefined} */
12
+ * @type {AuthorizationResourceModelClass | undefined} */
8
13
  static ModelClass = undefined
9
14
 
10
15
  /**
@@ -22,7 +27,9 @@ export default class AuthorizationBaseResource {
22
27
 
23
28
  /**
24
29
  * Runs model class.
25
- * @returns {typeof import("../database/record/index.js").default} - Model class handled by this resource.
30
+ * @template {AuthorizationResourceModelClass} TModelClass
31
+ * @this {{ModelClass: TModelClass | undefined, name: string}}
32
+ * @returns {TModelClass} - Model class handled by this resource.
26
33
  */
27
34
  static modelClass() {
28
35
  if (!this.ModelClass) {
@@ -41,7 +48,10 @@ export default class AuthorizationBaseResource {
41
48
  */
42
49
  can(actions, conditions) {
43
50
  this.assertResourceConditionsSignature({conditions, methodName: "can"})
44
- this.requiredAbility().can(actions, this.requiredModelClass(), /** @type {import("./ability.js").AbilityConditionsType<typeof import("../database/record/index.js").default> | undefined} */ (conditions))
51
+ // Authorization query rules are backend-only even when a shared resource is bound to a frontend model.
52
+ const modelClass = /** @type {typeof import("../database/record/index.js").default} */ (this.requiredModelClass())
53
+
54
+ this.requiredAbility().can(actions, modelClass, /** @type {import("./ability.js").AbilityConditionsType<typeof import("../database/record/index.js").default> | undefined} */ (conditions))
45
55
  }
46
56
 
47
57
  /**
@@ -53,7 +63,10 @@ export default class AuthorizationBaseResource {
53
63
  */
54
64
  cannot(actions, conditions) {
55
65
  this.assertResourceConditionsSignature({conditions, methodName: "cannot"})
56
- this.requiredAbility().cannot(actions, this.requiredModelClass(), /** @type {import("./ability.js").AbilityConditionsType<typeof import("../database/record/index.js").default> | undefined} */ (conditions))
66
+ // Authorization query rules are backend-only even when a shared resource is bound to a frontend model.
67
+ const modelClass = /** @type {typeof import("../database/record/index.js").default} */ (this.requiredModelClass())
68
+
69
+ this.requiredAbility().cannot(actions, modelClass, /** @type {import("./ability.js").AbilityConditionsType<typeof import("../database/record/index.js").default> | undefined} */ (conditions))
57
70
  }
58
71
 
59
72
  /**
@@ -70,7 +83,7 @@ export default class AuthorizationBaseResource {
70
83
 
71
84
  /**
72
85
  * Runs required model class.
73
- * @returns {typeof import("../database/record/index.js").default} - Model class handled by this resource.
86
+ * @returns {AuthorizationResourceModelClass} - Model class handled by this resource.
74
87
  */
75
88
  requiredModelClass() {
76
89
  const ResourceClass = /** @type {typeof AuthorizationBaseResource} */ (this.constructor)
@@ -325,7 +325,7 @@ export default class BackgroundJobsMain {
325
325
  }, this.workerLivenessSweepMs)
326
326
 
327
327
  if (this.lifecycleState === "active") {
328
- await this._startActiveOwnership()
328
+ await this._startActiveOwnership("active")
329
329
  } else if (this.lifecycleState === "retired") {
330
330
  this._startGenerationRecoveryOwnership()
331
331
  }
@@ -519,17 +519,27 @@ export default class BackgroundJobsMain {
519
519
 
520
520
  /**
521
521
  * Acquires scheduling and dispatch ownership for an active generation.
522
- * @returns {Promise<void>} - Resolves after active ownership is established.
522
+ * @param {"active" | "candidate"} expectedLifecycleState - State that still owns activation.
523
+ * @returns {Promise<boolean>} - Whether active ownership was established.
523
524
  */
524
- async _startActiveOwnership() {
525
+ async _startActiveOwnership(expectedLifecycleState) {
525
526
  await this.store.reconcileQueueConcurrency()
527
+ if (this.lifecycleState !== expectedLifecycleState) return false
526
528
  this._setupDispatchTriggers()
527
529
  this._setupStartupHandoffReclaim()
528
530
  this._startOrphanSweep()
529
531
  await this._startScheduler()
532
+ if (this.lifecycleState !== expectedLifecycleState) {
533
+ if (this.scheduler) await this.scheduler.stop()
534
+ this.scheduler = undefined
535
+ this._clearDispatchTimers()
536
+ this._disconnectBeaconHandlers()
537
+ return false
538
+ }
530
539
  this._activeOwnershipReady = true
531
540
  this._creditReadyWorkers()
532
541
  await this._drain()
542
+ return this.lifecycleState === expectedLifecycleState
533
543
  }
534
544
 
535
545
  /** Starts exact recovery duties without acquiring global dispatch ownership. */
@@ -603,7 +613,10 @@ export default class BackgroundJobsMain {
603
613
  */
604
614
  async _activate() {
605
615
  this.logger.info(() => ["Background jobs generation activation starting", {generationId: this.generationId}])
606
- await this._startActiveOwnership()
616
+ const ownershipStarted = await this._startActiveOwnership("candidate")
617
+ if (!ownershipStarted || this.lifecycleState !== "candidate") {
618
+ throw new Error("Background jobs generation retirement started before activation acquired ownership")
619
+ }
607
620
  this.lifecycleState = "active"
608
621
  this._creditReadyWorkers()
609
622
  this.logger.info(() => ["Background jobs generation activation acknowledged", {generationId: this.generationId}])
@@ -619,7 +632,8 @@ export default class BackgroundJobsMain {
619
632
  retire() {
620
633
  if (!this.generationId) throw new Error("Background jobs generation retirement requires generation mode")
621
634
  if (this.lifecycleState === "retiring" || this.lifecycleState === "retired") return Promise.resolve()
622
- if (this.lifecycleState !== "active") throw new Error(`Cannot retire background jobs generation from ${this.lifecycleState}`)
635
+ const activationInProgress = this.lifecycleState === "candidate" && Boolean(this._activationPromise)
636
+ if (this.lifecycleState !== "active" && !activationInProgress) throw new Error(`Cannot retire background jobs generation from ${this.lifecycleState}`)
623
637
 
624
638
  this.lifecycleState = "retiring"
625
639
  this._activeOwnershipReady = false
@@ -638,7 +652,8 @@ export default class BackgroundJobsMain {
638
652
  * @returns {Promise<void>} - Retirement fence completion.
639
653
  */
640
654
  async _retire() {
641
- await this.scheduler?.stop()
655
+ if (this._activationPromise) await Promise.allSettled([this._activationPromise])
656
+ if (this.scheduler) await this.scheduler.stop()
642
657
  this.scheduler = undefined
643
658
  if (this._drainPromise) await this._drainPromise
644
659
  if (this._stopped) return
@@ -66,12 +66,24 @@ const EXECUTION_MODE_BACKFILL_MIGRATION_VERSION = "20260607131010"
66
66
  // handoff-marker workaround), leaving `execution_mode` as the single source of
67
67
  // truth for a job's runtime.
68
68
  const DROP_FORKED_COLUMN_MIGRATION_VERSION = "20260719000000"
69
+ const JOBS_INDEX_REPAIR_MIGRATION_VERSION = "20260903120000"
69
70
  // Legacy marker prefix used by rows written before this migration: pooled jobs
70
71
  // used to persist as `execution_mode = "forked"` plus a `velocious-pooled:*`
71
72
  // handoff id. Retained only to detect and convert those rows in the migration.
72
73
  const LEGACY_POOLED_HANDOFF_ID_PREFIX = "velocious-pooled:"
73
74
  const LEGACY_POOLED_QUEUED_HANDOFF_ID = `${LEGACY_POOLED_HANDOFF_ID_PREFIX}queued`
74
75
  const JOBS_TABLE = "background_jobs"
76
+ const JOBS_INDEX_COLUMN_NAMES = [
77
+ "job_name",
78
+ "queue",
79
+ "status",
80
+ "scheduled_at_ms",
81
+ "created_at_ms",
82
+ "schedule_key",
83
+ "handed_off_at_ms",
84
+ "orphaned_at_ms",
85
+ "concurrency_key"
86
+ ]
75
87
  const IDEMPOTENCY_KEYS_TABLE = "background_job_idempotency_keys"
76
88
  const SCHEDULE_KEYS_TABLE = "background_job_schedule_keys"
77
89
  const CONCURRENCY_TABLE = "background_job_concurrency"
@@ -1838,6 +1850,50 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1838
1850
  await this._ensureQueueColumn(db)
1839
1851
  await this._ensureScheduleKeyColumn(db)
1840
1852
  await this._ensureJobTimeoutColumn(db)
1853
+ await this._ensureJobsTableIndexesOnce(db)
1854
+ }
1855
+
1856
+ /**
1857
+ * Repairs secondary indexes that older add-column upgrades declared but did
1858
+ * not create on every SQL driver. The migration ledger keeps routine store
1859
+ * readiness from repeatedly introspecting the full index set.
1860
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1861
+ * @returns {Promise<void>} - Resolves when all expected indexes exist.
1862
+ */
1863
+ async _ensureJobsTableIndexesOnce(db) {
1864
+ const migrationVersion = JOBS_INDEX_REPAIR_MIGRATION_VERSION
1865
+ const migrationKey = this._migrationKey(migrationVersion)
1866
+
1867
+ if (await this._hasMigration(db, migrationVersion)) return
1868
+
1869
+ const acquired = await db.acquireAdvisoryLock(migrationKey)
1870
+
1871
+ if (!acquired) throw new Error("Failed to acquire background jobs index repair lock")
1872
+
1873
+ try {
1874
+ if (await this._hasMigration(db, migrationVersion)) return
1875
+
1876
+ db.clearSchemaCache()
1877
+ const table = await db.getTableByNameOrFail(JOBS_TABLE)
1878
+ const indexedColumnNames = new Set(
1879
+ (await table.getIndexes())
1880
+ .filter((index) => !index.isPrimaryKey() && index.getColumnNames().length === 1)
1881
+ .map((index) => index.getColumnNames()[0])
1882
+ )
1883
+
1884
+ for (const columnName of JOBS_INDEX_COLUMN_NAMES) {
1885
+ if (indexedColumnNames.has(columnName)) continue
1886
+
1887
+ for (const sql of await db.createIndexSQLs({columns: [columnName], ifNotExists: db.getType() === "sqlite", tableName: JOBS_TABLE})) {
1888
+ await db.query(sql)
1889
+ }
1890
+ }
1891
+
1892
+ db.clearSchemaCache()
1893
+ await this._recordMigration(db, migrationVersion)
1894
+ } finally {
1895
+ await db.releaseAdvisoryLock(migrationKey)
1896
+ }
1841
1897
  }
1842
1898
 
1843
1899
  /**
@@ -2711,13 +2767,61 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2711
2767
  */
2712
2768
  async _reconcileConcurrency(db) {
2713
2769
  if (!(await db.tableExists(CONCURRENCY_TABLE))) return
2714
- const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
2715
- const jobsTable = db.quoteTable(JOBS_TABLE)
2716
- await db.query(
2717
- `UPDATE ${concurrencyTable} SET ${db.quoteColumn("active_count")} = (` +
2718
- `SELECT COUNT(*) FROM ${jobsTable} WHERE ${jobsTable}.${db.quoteColumn("status")} = ${db.quote("handed_off")} AND ` +
2719
- `${jobsTable}.${db.quoteColumn("concurrency_key")} = ${concurrencyTable}.${db.quoteColumn("concurrency_key")})`
2770
+ const activeRows = await db
2771
+ .newQuery()
2772
+ .from(JOBS_TABLE)
2773
+ .select("concurrency_key")
2774
+ .where({status: "handed_off"})
2775
+ .where(`${db.quoteColumn("concurrency_key")} IS NOT NULL`)
2776
+ .group("concurrency_key")
2777
+ .results()
2778
+ const staleRows = await db
2779
+ .newQuery()
2780
+ .from(CONCURRENCY_TABLE)
2781
+ .select("concurrency_key")
2782
+ .where(`${db.quoteColumn("active_count")} != 0`)
2783
+ .results()
2784
+ const concurrencyKeys = new Set(
2785
+ [...activeRows, ...staleRows].map((row) =>
2786
+ String(/** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (row).concurrency_key)
2787
+ )
2720
2788
  )
2789
+
2790
+ for (const concurrencyKey of [...concurrencyKeys].sort()) {
2791
+ await this._transactionResult(db, async () => {
2792
+ await this._reconcileConcurrencyKey(db, concurrencyKey)
2793
+ })
2794
+ }
2795
+ }
2796
+
2797
+ /**
2798
+ * Rebuilds one counter after locking it ahead of the job rows, matching the
2799
+ * lock order used by handoff and completion transitions.
2800
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
2801
+ * @param {string} concurrencyKey - Counter key.
2802
+ * @returns {Promise<void>} - Resolves when reconciled.
2803
+ */
2804
+ async _reconcileConcurrencyKey(db, concurrencyKey) {
2805
+ await this._lockConcurrencyRow(db, concurrencyKey)
2806
+ const rows = await db
2807
+ .newQuery()
2808
+ .from(JOBS_TABLE)
2809
+ .select("COUNT(*) AS active_count")
2810
+ .where({concurrency_key: concurrencyKey, status: "handed_off"})
2811
+ .results()
2812
+ const activeCount = this._normalizeNumber(
2813
+ /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (rows[0] || {}).active_count
2814
+ )
2815
+
2816
+ if (activeCount === null || !Number.isSafeInteger(activeCount) || activeCount < 0) {
2817
+ throw new Error(`Invalid reconciled background job concurrency count for ${concurrencyKey}: ${activeCount}`)
2818
+ }
2819
+
2820
+ await db.update({
2821
+ tableName: CONCURRENCY_TABLE,
2822
+ data: {active_count: activeCount},
2823
+ conditions: {concurrency_key: concurrencyKey}
2824
+ })
2721
2825
  }
2722
2826
 
2723
2827
  /**
@@ -2768,7 +2872,12 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2768
2872
  )
2769
2873
  }
2770
2874
 
2771
- const concurrencyRows = await db.newQuery().from(CONCURRENCY_TABLE).select("concurrency_key").results()
2875
+ const concurrencyRows = await db
2876
+ .newQuery()
2877
+ .from(CONCURRENCY_TABLE)
2878
+ .select("concurrency_key")
2879
+ .where(`${db.quoteColumn("concurrency_key")} LIKE ${db.quote(`${QUEUE_CONCURRENCY_KEY_PREFIX}%`)}`)
2880
+ .results()
2772
2881
 
2773
2882
  for (const row of concurrencyRows) {
2774
2883
  const concurrencyKey = String(/** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (row).concurrency_key)
@@ -375,6 +375,22 @@
375
375
  * @property {Record<string, ReturnType<typeof JSON.parse>>} [instance] - Optional custom attachment driver instance.
376
376
  */
377
377
 
378
+ /**
379
+ * Client-safe synchronization policy declared with a model attachment.
380
+ * @typedef {object} AttachmentSyncConfiguration
381
+ * @property {"eager" | "on-demand"} fetch - Whether clients prefetch the attachment or wait until it is requested.
382
+ * @property {"optional" | "required"} offlineRequirement - Whether an offline-ready scope requires the attachment bytes.
383
+ * @property {"durable" | "evictable"} retention - Whether clients may evict the attachment under storage pressure.
384
+ */
385
+
386
+ /**
387
+ * Model attachment declaration retained by the record class.
388
+ * @typedef {object} RecordAttachmentConfiguration
389
+ * @property {string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>} [driver] - Attachment driver name, class, or instance.
390
+ * @property {AttachmentSyncConfiguration} [sync] - Client-safe synchronized asset policy.
391
+ * @property {"hasOne" | "hasMany"} type - Attachment cardinality.
392
+ */
393
+
378
394
  /**
379
395
  * @typedef {object} AttachmentsConfiguration
380
396
  * @property {string} [defaultDriver] - Default attachment storage driver name.
@@ -418,6 +434,7 @@
418
434
 
419
435
  /**
420
436
  * @typedef {object} FrontendModelAttachmentConfiguration
437
+ * @property {AttachmentSyncConfiguration} [sync] - Client-side synchronized asset policy.
421
438
  * @property {"hasOne" | "hasMany"} type - Attachment cardinality.
422
439
  */
423
440
 
@@ -574,11 +591,26 @@
574
591
  */
575
592
 
576
593
  /**
577
- * @typedef {Omit<typeof import("./frontend-model-resource/base-resource.js").default, never> & {new (args: import("./frontend-model-resource/base-resource.js").FrontendModelResourceAbilityArgs | import("./frontend-model-resource/base-resource.js").FrontendModelResourceControllerArgs): import("./frontend-model-resource/base-resource.js").default<typeof import("./database/record/index.js").default>}} FrontendModelResourceClassType
594
+ * Unbound resource class used by model-agnostic registries.
595
+ * @typedef {Omit<typeof import("./frontend-model-resource/base-resource.js").default, "modelClass"> & {modelClass: () => typeof import("./database/record/index.js").default, new (args: never): import("./frontend-model-resource/base-resource.js").default<typeof import("./database/record/index.js").default>}} UnboundFrontendModelResourceClassType
596
+ */
597
+
598
+ /**
599
+ * Resource class bound to a specific model class.
600
+ * @template {import("./frontend-model-resource/base-resource.js").FrontendModelResourceModelClass} TModelClass
601
+ * @template {typeof import("./database/record/index.js").default} TDatabaseModelClass
602
+ * @typedef {Omit<typeof import("./frontend-model-resource/base-resource.js").default, "ModelClass" | "modelClass"> & {ModelClass: TModelClass | undefined, modelClass: () => TModelClass, new (args: import("./frontend-model-resource/base-resource.js").FrontendModelResourceAbilityArgs<TModelClass> | import("./frontend-model-resource/base-resource.js").FrontendModelResourceControllerArgs<TDatabaseModelClass>): import("./frontend-model-resource/base-resource.js").default<TModelClass, TDatabaseModelClass>}} BoundFrontendModelResourceClassType
603
+ */
604
+
605
+ /**
606
+ * @template {import("./frontend-model-resource/base-resource.js").FrontendModelResourceModelClass} [TModelClass=never]
607
+ * @template {typeof import("./database/record/index.js").default} [TDatabaseModelClass=Extract<TModelClass, typeof import("./database/record/index.js").default>]
608
+ * @typedef {[TModelClass] extends [never] ? UnboundFrontendModelResourceClassType : BoundFrontendModelResourceClassType<TModelClass, TDatabaseModelClass>} FrontendModelResourceClassType
578
609
  */
579
610
 
580
611
  /**
581
- * @typedef {FrontendModelResourceClassType} FrontendModelResourceDefinition
612
+ * @template {import("./frontend-model-resource/base-resource.js").FrontendModelResourceModelClass} [TModelClass=never]
613
+ * @typedef {FrontendModelResourceClassType<TModelClass>} FrontendModelResourceDefinition
582
614
  */
583
615
 
584
616
  /**
@@ -71,6 +71,8 @@ import UUID from "pure-uuid"
71
71
  * AttachmentDriverConstructor type.
72
72
  * @typedef {import("../../configuration-types.js").AttachmentDriverConstructor} AttachmentDriverConstructor
73
73
  */
74
+ /** @typedef {import("../../configuration-types.js").AttachmentSyncConfiguration} AttachmentSyncConfiguration */
75
+ /** @typedef {import("../../configuration-types.js").RecordAttachmentConfiguration} RecordAttachmentConfiguration */
74
76
 
75
77
  /** Stored values that a declared `"boolean"` cast reads back as `true`. */
76
78
  const declaredBooleanTruthyValues = new Set([1, true, "1"])
@@ -238,7 +240,7 @@ class VelociousDatabaseRecord {
238
240
  static _lifecycleCallbacks = undefined
239
241
  /** @type {Record<string, typeof import("./validators/base.js").default> | undefined} */
240
242
  static _validatorTypes = undefined
241
- /** @type {Record<string, {driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, type: "hasOne" | "hasMany"}> | undefined} */
243
+ /** @type {Record<string, RecordAttachmentConfiguration> | undefined} */
242
244
  static _attachmentsMap = undefined
243
245
  /** @type {Record<string, import("./relationships/base.js").default> | undefined} */
244
246
  static _relationships = undefined
@@ -494,13 +496,13 @@ class VelociousDatabaseRecord {
494
496
 
495
497
  /**
496
498
  * Runs get attachments map.
497
- * @returns {Record<string, {driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, type: "hasOne" | "hasMany"}>} - Attachment definitions keyed by name.
499
+ * @returns {Record<string, RecordAttachmentConfiguration>} - Attachment definitions keyed by name.
498
500
  */
499
501
  static getAttachmentsMap() {
500
502
  if (!this._attachmentsMap) {
501
503
  /**
502
504
  * Narrows the runtime value to the documented type.
503
- * @type {Record<string, {driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, type: "hasOne" | "hasMany"}>} */
505
+ * @type {Record<string, RecordAttachmentConfiguration>} */
504
506
  this._attachmentsMap = {}
505
507
  }
506
508
 
@@ -1165,16 +1167,25 @@ class VelociousDatabaseRecord {
1165
1167
 
1166
1168
  /**
1167
1169
  * Runs get attachments.
1168
- * @returns {Record<string, {driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, type: "hasOne" | "hasMany"}>} - Attachment definitions.
1170
+ * @returns {Record<string, RecordAttachmentConfiguration>} - Attachment definitions.
1169
1171
  */
1170
1172
  static getAttachments() {
1171
1173
  return this.getAttachmentsMap()
1172
1174
  }
1173
1175
 
1176
+ /**
1177
+ * Returns attachment definitions through the model contract shared with
1178
+ * frontend model classes.
1179
+ * @returns {Record<string, RecordAttachmentConfiguration>} - Attachment definitions.
1180
+ */
1181
+ static attachmentDefinitions() {
1182
+ return this.getAttachmentsMap()
1183
+ }
1184
+
1174
1185
  /**
1175
1186
  * Runs get attachment by name.
1176
1187
  * @param {string} attachmentName - Attachment name.
1177
- * @returns {{driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, type: "hasOne" | "hasMany"}} - Attachment definition.
1188
+ * @returns {RecordAttachmentConfiguration} - Attachment definition.
1178
1189
  */
1179
1190
  static getAttachmentByName(attachmentName) {
1180
1191
  const definition = this.getAttachmentsMap()[attachmentName]
@@ -1443,14 +1454,34 @@ class VelociousDatabaseRecord {
1443
1454
  * @param {string} attachmentName - Attachment name.
1444
1455
  * @param {object} args - Attachment args.
1445
1456
  * @param {string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>} [args.driver] - Attachment driver name, class, or instance.
1457
+ * @param {AttachmentSyncConfiguration} [args.sync] - Client-safe synchronized asset policy.
1446
1458
  * @param {"hasOne" | "hasMany"} args.type - Attachment type.
1447
1459
  * @returns {void} - No return value.
1448
1460
  */
1449
- static _defineAttachment(attachmentName, {driver, type}) {
1461
+ static _defineAttachment(attachmentName, {driver, sync, type}) {
1450
1462
  if (!attachmentName || typeof attachmentName !== "string") throw new Error(`Invalid attachment name: ${attachmentName}`)
1451
1463
  if (attachmentName in this.getAttachmentsMap()) throw new Error(`Attachment ${attachmentName} already exists`)
1452
1464
 
1453
- this.getAttachmentsMap()[attachmentName] = {driver, type}
1465
+ if (sync) {
1466
+ const {fetch, offlineRequirement, retention, ...restSync} = sync
1467
+
1468
+ restArgsError(restSync)
1469
+
1470
+ if (fetch !== "eager" && fetch !== "on-demand") {
1471
+ throw new Error(`Attachment ${attachmentName} sync fetch must be eager or on-demand`)
1472
+ }
1473
+ if (offlineRequirement !== "optional" && offlineRequirement !== "required") {
1474
+ throw new Error(`Attachment ${attachmentName} offline requirement must be optional or required`)
1475
+ }
1476
+ if (retention !== "durable" && retention !== "evictable") {
1477
+ throw new Error(`Attachment ${attachmentName} sync retention must be durable or evictable`)
1478
+ }
1479
+ if (offlineRequirement === "required" && retention !== "durable") {
1480
+ throw new Error(`Attachment ${attachmentName} required offline assets must use durable retention`)
1481
+ }
1482
+ }
1483
+
1484
+ this.getAttachmentsMap()[attachmentName] = {driver, sync, type}
1454
1485
 
1455
1486
  const prototype = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (/** @type {ReturnType<typeof JSON.parse>} */ (this.prototype))
1456
1487
 
@@ -1467,21 +1498,21 @@ class VelociousDatabaseRecord {
1467
1498
  /**
1468
1499
  * Adds a single attachment helper to the model.
1469
1500
  * @param {string} attachmentName - Attachment name.
1470
- * @param {{driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>}} [args] - Attachment options.
1501
+ * @param {{driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, sync?: AttachmentSyncConfiguration}} [args] - Attachment options.
1471
1502
  * @returns {void} - No return value.
1472
1503
  */
1473
1504
  static hasOneAttachment(attachmentName, args = {}) {
1474
- this._defineAttachment(attachmentName, {driver: args.driver, type: "hasOne"})
1505
+ this._defineAttachment(attachmentName, {driver: args.driver, sync: args.sync, type: "hasOne"})
1475
1506
  }
1476
1507
 
1477
1508
  /**
1478
1509
  * Adds a collection attachment helper to the model.
1479
1510
  * @param {string} attachmentName - Attachment name.
1480
- * @param {{driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>}} [args] - Attachment options.
1511
+ * @param {{driver?: string | AttachmentDriverConstructor | Record<string, ReturnType<typeof JSON.parse>>, sync?: AttachmentSyncConfiguration}} [args] - Attachment options.
1481
1512
  * @returns {void} - No return value.
1482
1513
  */
1483
1514
  static hasManyAttachments(attachmentName, args = {}) {
1484
- this._defineAttachment(attachmentName, {driver: args.driver, type: "hasMany"})
1515
+ this._defineAttachment(attachmentName, {driver: args.driver, sync: args.sync, type: "hasMany"})
1485
1516
  }
1486
1517
 
1487
1518
  /**
@@ -5,6 +5,7 @@ import path from "node:path"
5
5
  import * as inflection from "inflection"
6
6
  import {frontendModelResourceIsBuiltIn, frontendModelResourcesWithBuiltInsForBackendProject} from "../../../../../frontend-models/built-in-resources.js"
7
7
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition} from "../../../../../frontend-models/resource-definition.js"
8
+ import {frontendModelResourceInternalConstructor} from "../../../../../frontend-model-resource/base-resource.js"
8
9
 
9
10
  /**
10
11
  * Attribute metadata used for generated frontend-model JSDoc.
@@ -381,7 +382,18 @@ export default class DbGenerateFrontendModels extends BaseCommand {
381
382
  ? "hasMany"
382
383
  : "hasOne"
383
384
 
384
- fileContent += ` ${attachmentName}: {type: ${JSON.stringify(attachmentType)}},\n`
385
+ if (attachmentConfig.sync) {
386
+ fileContent += ` ${attachmentName}: {\n`
387
+ fileContent += " sync: {\n"
388
+ fileContent += ` fetch: ${JSON.stringify(attachmentConfig.sync.fetch)},\n`
389
+ fileContent += ` offlineRequirement: ${JSON.stringify(attachmentConfig.sync.offlineRequirement)},\n`
390
+ fileContent += ` retention: ${JSON.stringify(attachmentConfig.sync.retention)},\n`
391
+ fileContent += " },\n"
392
+ fileContent += ` type: ${JSON.stringify(attachmentType)}\n`
393
+ fileContent += " },\n"
394
+ } else {
395
+ fileContent += ` ${attachmentName}: {type: ${JSON.stringify(attachmentType)}},\n`
396
+ }
385
397
  }
386
398
  fileContent += " },\n"
387
399
  }
@@ -857,7 +869,8 @@ export default class DbGenerateFrontendModels extends BaseCommand {
857
869
  try {
858
870
  const modelClass = resourceClass.modelClass()
859
871
 
860
- const instance = new resourceClass({
872
+ const ResourceClass = frontendModelResourceInternalConstructor(resourceClass)
873
+ const instance = new ResourceClass({
861
874
  ability: undefined,
862
875
  context: {},
863
876
  locals: {},
@@ -894,7 +907,8 @@ export default class DbGenerateFrontendModels extends BaseCommand {
894
907
  try {
895
908
  const modelClass = resourceClass.modelClass()
896
909
 
897
- const instance = new resourceClass({
910
+ const ResourceClass = frontendModelResourceInternalConstructor(resourceClass)
911
+ const instance = new ResourceClass({
898
912
  ability: undefined,
899
913
  context: {},
900
914
  locals: {},
@@ -3,7 +3,7 @@
3
3
  import {randomUUID} from "node:crypto"
4
4
  import * as inflection from "inflection"
5
5
  import Controller from "./controller.js"
6
- import FrontendModelBaseResource from "./frontend-model-resource/base-resource.js"
6
+ import FrontendModelBaseResource, {frontendModelResourceInternalConstructor} from "./frontend-model-resource/base-resource.js"
7
7
  import Response from "./http-server/client/response.js"
8
8
  import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
9
9
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject, frontendModelSyncManifestForBackendProjects} from "./frontend-models/resource-definition.js"
@@ -70,7 +70,7 @@ import {RansackQueryError, normalizeRansackGroup, parseRansackSort} from "./util
70
70
  * @typedef {object} FrontendModelIndexQueryOptions
71
71
  * @property {boolean} [includePagination] - Whether frontend-model pagination params should be applied.
72
72
  * @property {boolean} [includeSort] - Whether frontend-model sort params should be applied.
73
- * @property {import("./frontend-model-resource/base-resource.js").default} [resource] - Resource providing query hooks.
73
+ * @property {Pick<import("./frontend-model-resource/base-resource.js").default<import("./frontend-model-resource/base-resource.js").FrontendModelResourceModelClass>, "applyFrontendModelIndexPagination" | "applyFrontendModelIndexSearch" | "applyFrontendModelIndexSort">} [resource] - Resource providing query hooks.
74
74
  */
75
75
  /** @typedef {import("./database/query/model-class-query.js").default & Record<symbol, Set<string> | undefined>} FrontendModelQueryMetadata */
76
76
  /**
@@ -1040,7 +1040,9 @@ export default class FrontendModelController extends Controller {
1040
1040
  resourceConfiguration: frontendModelResource.resourceConfiguration
1041
1041
  }
1042
1042
 
1043
- return new frontendModelResource.resourceClass(resourceArgs)
1043
+ const ResourceClass = frontendModelResourceInternalConstructor(frontendModelResource.resourceClass)
1044
+
1045
+ return new ResourceClass(resourceArgs)
1044
1046
  }
1045
1047
 
1046
1048
  /**
@@ -2908,7 +2910,9 @@ export default class FrontendModelController extends Controller {
2908
2910
  const resourceClass = resourceDefinition ? frontendModelResourceClassFromDefinition(resourceDefinition) : null
2909
2911
 
2910
2912
  if (resourceClass) {
2911
- resource = new resourceClass({
2913
+ const ResourceClass = frontendModelResourceInternalConstructor(resourceClass)
2914
+
2915
+ resource = new ResourceClass({
2912
2916
  ability: this.currentAbility(),
2913
2917
  // Propagate the controller so a related/preloaded model's serialization
2914
2918
  // resource can use request context (e.g. `requestBaseUrl()` for signed
@@ -3985,7 +3989,8 @@ export default class FrontendModelController extends Controller {
3985
3989
 
3986
3990
  if (!frontendModelResource) throw frontendSyncReplaySafeError(`Sync replay model is not enabled: ${mutation.model}`)
3987
3991
 
3988
- const resource = new frontendModelResource.resourceClass({
3992
+ const ResourceClass = frontendModelResourceInternalConstructor(frontendModelResource.resourceClass)
3993
+ const resource = new ResourceClass({
3989
3994
  ability: this.currentAbility(),
3990
3995
  controller: this,
3991
3996
  context: {