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.
- package/README.md +25 -2
- package/build/authorization/base-resource.js +18 -5
- package/build/background-jobs/main.js +21 -6
- package/build/background-jobs/store.js +116 -7
- package/build/configuration-types.js +34 -2
- package/build/database/record/index.js +42 -11
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +17 -3
- package/build/frontend-model-controller.js +10 -5
- package/build/frontend-model-resource/base-resource.js +95 -41
- package/build/frontend-models/base.js +1 -1
- package/build/src/authorization/base-resource.d.ts +19 -6
- package/build/src/authorization/base-resource.d.ts.map +1 -1
- package/build/src/authorization/base-resource.js +16 -6
- package/build/src/background-jobs/main.d.ts +3 -2
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +26 -7
- package/build/src/background-jobs/store.d.ts +16 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +103 -7
- package/build/src/configuration-types.d.ts +72 -5
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +31 -3
- package/build/src/database/record/index.d.ts +44 -46
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +39 -12
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +19 -4
- package/build/src/frontend-model-controller.d.ts +1 -1
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +9 -6
- package/build/src/frontend-model-resource/base-resource.d.ts +66 -32
- package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
- package/build/src/frontend-model-resource/base-resource.js +89 -42
- package/build/src/frontend-models/base.d.ts +1 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +2 -2
- package/build/src/sync/sync-api-controller.d.ts.map +1 -1
- package/build/src/sync/sync-api-controller.js +6 -5
- package/build/src/sync/sync-envelope-replay-service.d.ts +1 -1
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +3 -2
- package/build/src/sync/sync-resource-base.d.ts +2 -0
- package/build/src/sync/sync-resource-base.d.ts.map +1 -1
- package/build/src/sync/sync-resource-base.js +3 -1
- package/build/sync/sync-api-controller.js +5 -5
- package/build/sync/sync-envelope-replay-service.js +2 -1
- package/build/sync/sync-resource-base.js +3 -0
- package/package.json +1 -1
- package/src/authorization/base-resource.js +18 -5
- package/src/background-jobs/main.js +21 -6
- package/src/background-jobs/store.js +116 -7
- package/src/configuration-types.js +34 -2
- package/src/database/record/index.js +42 -11
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +17 -3
- package/src/frontend-model-controller.js +10 -5
- package/src/frontend-model-resource/base-resource.js +95 -41
- package/src/frontend-models/base.js +1 -1
- package/src/sync/sync-api-controller.js +5 -5
- package/src/sync/sync-envelope-replay-service.js +2 -1
- 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,
|
|
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
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
-
|
|
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.
|
|
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
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
`${
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3992
|
+
const ResourceClass = frontendModelResourceInternalConstructor(frontendModelResource.resourceClass)
|
|
3993
|
+
const resource = new ResourceClass({
|
|
3989
3994
|
ability: this.currentAbility(),
|
|
3990
3995
|
controller: this,
|
|
3991
3996
|
context: {
|