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
@@ -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: {
@@ -5,6 +5,11 @@ import * as inflection from "inflection"
5
5
  import isPlainObject from "../utils/plain-object.js"
6
6
  import VelociousError from "../velocious-error.js"
7
7
 
8
+ /**
9
+ * Backend or frontend model class bound to a frontend-model resource.
10
+ * @typedef {import("../authorization/base-resource.js").AuthorizationResourceModelClass & {attachmentDefinitions: () => Record<string, import("../configuration-types.js").FrontendModelAttachmentConfiguration>, primaryKey: () => string}} FrontendModelResourceModelClass
11
+ */
12
+
8
13
  /**
9
14
  * Built-in frontend-model resource action.
10
15
  * @typedef {"index" | "find" | "create" | "update" | "destroy" | "attach" | "attachmentList" | "download" | "url"} FrontendModelResourceAction
@@ -20,7 +25,7 @@ import VelociousError from "../velocious-error.js"
20
25
  * frontendModelAbilityAction: (action: FrontendModelResourceAction) => string,
21
26
  * frontendModelAbilityAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
22
27
  * frontendModelAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
23
- * frontendModelIndexQuery: (options?: FrontendModelResourceIndexQueryOptions & {resource?: FrontendModelBaseResource}) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
28
+ * frontendModelIndexQuery: (options?: FrontendModelResourceIndexQueryOptions & {resource?: Pick<FrontendModelBaseResource<FrontendModelResourceModelClass>, "applyFrontendModelIndexPagination" | "applyFrontendModelIndexSearch" | "applyFrontendModelIndexSort">}) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
24
29
  * frontendModelParams: () => import("../configuration-types.js").VelociousParams,
25
30
  * frontendModelPreload: () => import("../database/query/index.js").NestedPreloadRecord | null,
26
31
  * frontendModelResourceConfigurationForModelClass: (modelClass: typeof import("../database/record/index.js").default) => FrontendModelResolvedResourceConfiguration | null,
@@ -68,9 +73,10 @@ import VelociousError from "../velocious-error.js"
68
73
 
69
74
  /**
70
75
  * FrontendModelResourceControllerArgs type.
76
+ * @template {typeof import("../database/record/index.js").default} [TDatabaseModelClass=typeof import("../database/record/index.js").default]
71
77
  * @typedef {object} FrontendModelResourceControllerArgs
72
78
  * @property {FrontendModelResourceController} controller - Frontend-model controller instance.
73
- * @property {typeof import("../database/record/index.js").default} modelClass - Backing model class.
79
+ * @property {TDatabaseModelClass} modelClass - Backing model class.
74
80
  * @property {string} modelName - Model name.
75
81
  * @property {import("../configuration-types.js").VelociousParams} params - Request params.
76
82
  * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration | import("../configuration-types.js").FrontendModelResourceConfiguration} resourceConfiguration - Normalized resource configuration (or raw input shape during early bootstrap).
@@ -78,17 +84,26 @@ import VelociousError from "../velocious-error.js"
78
84
 
79
85
  /**
80
86
  * FrontendModelResourceAbilityArgs type.
87
+ * @template {FrontendModelResourceModelClass} [TModelClass=FrontendModelResourceModelClass]
81
88
  * @typedef {object} FrontendModelResourceAbilityArgs
82
89
  * @property {import("../authorization/ability.js").default} [ability] - Ability instance when the resource is used directly for authorization.
83
90
  * @property {import("../configuration.js").default} [configuration] - Velocious configuration for controller-less construction (for example the sync websocket channel); the controller path derives it from the controller instead.
84
91
  * @property {import("../configuration-types.js").VelociousLooseObject} [context] - Ability context.
85
92
  * @property {import("../configuration-types.js").VelociousLooseObject} [locals] - Ability locals.
86
- * @property {typeof import("../database/record/index.js").default} [modelClass] - Optional backing model class override.
93
+ * @property {TModelClass} [modelClass] - Optional backing model class override.
87
94
  * @property {string} [modelName] - Optional model name override.
88
95
  * @property {import("../configuration-types.js").VelociousParams} [params] - Optional params override.
89
96
  * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration | import("../configuration-types.js").FrontendModelResourceConfiguration} [resourceConfiguration] - Optional normalized resource configuration.
90
97
  */
91
98
 
99
+ /**
100
+ * Internal constructor contract used when a resource instantiates its shared
101
+ * counterpart across the frontend/backend model boundary.
102
+ * @template {FrontendModelResourceModelClass} TModelClass
103
+ * @template {typeof import("../database/record/index.js").default} TDatabaseModelClass
104
+ * @typedef {{new (args: FrontendModelResourceAbilityArgs<FrontendModelResourceModelClass> | FrontendModelResourceControllerArgs): FrontendModelBaseResource<TModelClass, TDatabaseModelClass>}} FrontendModelResourceInternalConstructor
105
+ */
106
+
92
107
  /**
93
108
  * Normalized sync replay mutation passed to the resource sync hooks.
94
109
  * @typedef {import("../sync/sync-envelope-replay-service.js").SyncReplayMutation} FrontendModelSyncMutation
@@ -163,19 +178,30 @@ import VelociousError from "../velocious-error.js"
163
178
  * @typedef {FrontendModelResourceAttributePayload & {id?: string | number, _destroy?: boolean, attributes?: FrontendModelResourceAttributePayload, attachments?: FrontendModelResourceAttributePayload, nestedAttributes?: FrontendModelResourceAttributePayload}} FrontendModelResourceNestedEntry
164
179
  */
165
180
 
181
+ /**
182
+ * Narrows an unbound resource registry entry at framework-owned construction
183
+ * sites where the backing database model has already been resolved.
184
+ * @param {import("../configuration-types.js").FrontendModelResourceClassType} ResourceClass - Unbound resource class.
185
+ * @returns {FrontendModelResourceInternalConstructor<typeof import("../database/record/index.js").default, typeof import("../database/record/index.js").default>} Runtime constructor.
186
+ */
187
+ export function frontendModelResourceInternalConstructor(ResourceClass) {
188
+ return /** @type {FrontendModelResourceInternalConstructor<typeof import("../database/record/index.js").default, typeof import("../database/record/index.js").default>} */ (/** @type {unknown} */ (ResourceClass))
189
+ }
190
+
166
191
  /**
167
192
  * Base class for backend frontend-model resources.
168
- * @template {typeof import("../database/record/index.js").default} [TModelClass=typeof import("../database/record/index.js").default]
193
+ * @template {FrontendModelResourceModelClass} [TModelClass=typeof import("../database/record/index.js").default]
194
+ * @template {typeof import("../database/record/index.js").default} [TDatabaseModelClass=Extract<TModelClass, typeof import("../database/record/index.js").default>]
169
195
  */
170
196
  export default class FrontendModelBaseResource extends AuthorizationBaseResource {
171
- /** @type {typeof import("../database/record/index.js").default | undefined} */
197
+ /** @type {FrontendModelResourceModelClass | undefined} */
172
198
  static ModelClass = undefined
173
199
 
174
200
  /** @type {Record<string, ReturnType<typeof JSON.parse>> | string[] | undefined} */
175
201
  static attributes = undefined
176
202
  /** @type {string[] | undefined} */
177
203
  static abilities = undefined
178
- /** @type {Record<string, ReturnType<typeof JSON.parse>> | undefined} */
204
+ /** @type {Record<string, import("../configuration-types.js").FrontendModelAttachmentConfiguration> | undefined} */
179
205
  static attachments = undefined
180
206
  /** @type {string[] | undefined} */
181
207
  static commands = undefined
@@ -214,7 +240,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
214
240
 
215
241
  /**
216
242
  * Runs constructor.
217
- * @param {FrontendModelResourceAbilityArgs | FrontendModelResourceControllerArgs} args - Resource args.
243
+ * @param {FrontendModelResourceAbilityArgs<TModelClass> | FrontendModelResourceControllerArgs<TDatabaseModelClass>} args - Resource args.
218
244
  */
219
245
  constructor(args) {
220
246
  super({
@@ -223,16 +249,18 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
223
249
  locals: "locals" in args ? args.locals || {} : {}
224
250
  })
225
251
 
226
- const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
252
+ // Narrows the subclass static side to the model class carried by this resource generic.
253
+ const ResourceClass = /** @type {typeof FrontendModelBaseResource & {ModelClass: TModelClass | undefined, modelClass: () => TModelClass}} */ (this.constructor)
227
254
  const defaultResourceConfiguration = /** @type {import("../configuration-types.js").FrontendModelResourceConfiguration} */ ({attributes: []})
228
255
 
229
256
  this.controller = "controller" in args ? args.controller : undefined
230
257
  this.configurationValue = "configuration" in args ? args.configuration : undefined
231
- this.modelClassValue = "modelClass" in args ? args.modelClass : ResourceClass.modelClass()
258
+ // Narrows the internal controller/shared-resource construction path to the resource's declared model generic.
259
+ this.modelClassValue = /** @type {TModelClass} */ ("modelClass" in args ? args.modelClass : ResourceClass.modelClass())
232
260
  this.modelNameValue = "modelName" in args ? args.modelName : this.modelClass().getModelName()
233
261
  this.paramsValue = "params" in args ? args.params : undefined
234
262
  this.resourceConfigurationValue = "resourceConfiguration" in args ? args.resourceConfiguration : defaultResourceConfiguration
235
- /** @type {FrontendModelBaseResource | null | undefined} */
263
+ /** @type {FrontendModelBaseResource<TModelClass, TDatabaseModelClass> | null | undefined} */
236
264
  this.sharedResourceInstanceValue = undefined
237
265
  }
238
266
 
@@ -269,15 +297,37 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
269
297
  return /** @type {string[] | undefined} */ (this.sharedResourceStaticValue("translatedAttributes"))
270
298
  }
271
299
 
300
+ /**
301
+ * Resolves frontend-safe attachment declarations from the backing model.
302
+ * Resource-level declarations remain as a fallback for frontend-only resources.
303
+ * @returns {Record<string, import("../configuration-types.js").FrontendModelAttachmentConfiguration>} - Client attachment configuration keyed by name.
304
+ */
305
+ static attachmentConfigurations() {
306
+ const configuredAttachments = /** @type {Record<string, import("../configuration-types.js").FrontendModelAttachmentConfiguration> | undefined} */ (this.sharedResourceStaticValue("attachments"))
307
+ const attachments = configuredAttachments ? {...configuredAttachments} : {}
308
+
309
+ if (!this.ModelClass) return attachments
310
+
311
+ for (const [attachmentName, definition] of Object.entries(this.ModelClass.attachmentDefinitions())) {
312
+ const attachmentConfig = /** @type {import("../configuration-types.js").FrontendModelAttachmentConfiguration} */ ({type: definition.type})
313
+
314
+ if (definition.sync) attachmentConfig.sync = {...definition.sync}
315
+
316
+ attachments[attachmentName] = attachmentConfig
317
+ }
318
+
319
+ return attachments
320
+ }
321
+
272
322
  /**
273
323
  * Builds a resource instance for shared-resource fallback calls.
274
- * @returns {FrontendModelBaseResource | null} - Shared resource instance when configured.
324
+ * @returns {FrontendModelBaseResource<TModelClass, TDatabaseModelClass> | null} - Shared resource instance when configured.
275
325
  */
276
326
  sharedResourceInstance() {
277
327
  if (this.sharedResourceInstanceValue !== undefined) return this.sharedResourceInstanceValue
278
328
 
279
- const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
280
- const SharedResource = /** @type {typeof FrontendModelBaseResource | undefined} */ (ResourceClass.sharedResourceClass())
329
+ const ResourceClass = /** @type {import("../configuration-types.js").FrontendModelResourceClassType<TModelClass, TDatabaseModelClass>} */ (this.constructor)
330
+ const SharedResource = /** @type {import("../configuration-types.js").FrontendModelResourceClassType<TModelClass, TDatabaseModelClass> | undefined} */ (ResourceClass.sharedResourceClass())
281
331
 
282
332
  if (!SharedResource) {
283
333
  this.sharedResourceInstanceValue = null
@@ -288,7 +338,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
288
338
  throw new Error(`${ResourceClass.name}.SharedResource cannot point to itself.`)
289
339
  }
290
340
 
291
- this.sharedResourceInstanceValue = new SharedResource({
341
+ const SharedResourceConstructor = /** @type {FrontendModelResourceInternalConstructor<TModelClass, TDatabaseModelClass>} */ (/** @type {unknown} */ (SharedResource))
342
+ const sharedResource = new SharedResourceConstructor({
292
343
  ability: this.ability,
293
344
  controller: this.controller,
294
345
  context: this.context,
@@ -298,8 +349,9 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
298
349
  params: this.params(),
299
350
  resourceConfiguration: this.resourceConfiguration()
300
351
  })
352
+ this.sharedResourceInstanceValue = sharedResource
301
353
 
302
- return this.sharedResourceInstanceValue
354
+ return sharedResource
303
355
  }
304
356
 
305
357
  /**
@@ -343,7 +395,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
343
395
  /**
344
396
  * Resolves a method on this resource or its shared fallback.
345
397
  * @param {string} methodName - Method name.
346
- * @returns {{method: (...methodArgs: unknown[]) => unknown, resource: FrontendModelBaseResource} | null} - Resolved method and receiver.
398
+ * @returns {{method: (...methodArgs: unknown[]) => unknown, resource: FrontendModelBaseResource<TModelClass, TDatabaseModelClass>} | null} - Resolved method and receiver.
347
399
  */
348
400
  resourceMethod(methodName) {
349
401
  const ownMethod = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (this))[methodName]
@@ -392,7 +444,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
392
444
  static resourceConfig() {
393
445
  const attributes = this.sharedResourceStaticValue("attributes")
394
446
  const abilities = this.sharedResourceStaticValue("abilities")
395
- const attachments = this.sharedResourceStaticValue("attachments")
447
+ const attachments = this.attachmentConfigurations()
396
448
  const commands = this.sharedResourceStaticValue("commands")
397
449
  const builtInCollectionCommands = this.sharedResourceStaticValue("builtInCollectionCommands")
398
450
  const builtInMemberCommands = this.sharedResourceStaticValue("builtInMemberCommands")
@@ -409,7 +461,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
409
461
  }
410
462
 
411
463
  if (abilities) config.abilities = /** @type {string[]} */ (abilities)
412
- if (attachments) config.attachments = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (attachments)
464
+ if (Object.keys(attachments).length > 0) config.attachments = attachments
413
465
  if (commands) config.commands = /** @type {string[]} */ (commands)
414
466
  if (builtInCollectionCommands) config.builtInCollectionCommands = /** @type {string[]} */ (builtInCollectionCommands)
415
467
  if (builtInMemberCommands) config.builtInMemberCommands = /** @type {string[]} */ (builtInMemberCommands)
@@ -424,16 +476,6 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
424
476
  return config
425
477
  }
426
478
 
427
- /**
428
- * Runs static model class.
429
- * @returns {typeof import("../database/record/index.js").default} - Backing model class.
430
- */
431
- static modelClass() {
432
- if (!this.ModelClass) throw new Error(`${this.name} requires a static ModelClass.`)
433
-
434
- return this.ModelClass
435
- }
436
-
437
479
  /**
438
480
  * Runs controller instance.
439
481
  * @returns {import("../controller.js").default} - Controller instance.
@@ -466,7 +508,16 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
466
508
  throw new Error(`${this.constructor.name} requires a model class.`)
467
509
  }
468
510
 
469
- return /** @type {TModelClass} */ (this.modelClassValue)
511
+ return this.modelClassValue
512
+ }
513
+
514
+ /**
515
+ * Returns the database model class used by server-only resource operations.
516
+ * @returns {TDatabaseModelClass} - Database model class.
517
+ */
518
+ databaseModelClass() {
519
+ // Narrows the portable resource generic at the explicit backend-operation boundary.
520
+ return /** @type {TDatabaseModelClass} */ (/** @type {unknown} */ (this.modelClass()))
470
521
  }
471
522
 
472
523
  /**
@@ -619,7 +670,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
619
670
  * @returns {Promise<import("../database/record/index.js").default | null>} Existing record or null.
620
671
  */
621
672
  async findSyncRecord({ability = this.ability, forDelete = false, mutation}) {
622
- const ModelClass = this.modelClass()
673
+ const ModelClass = this.databaseModelClass()
623
674
  const primaryKey = ModelClass.primaryKey()
624
675
  const query = ability
625
676
  ? ModelClass.accessibleFor(this.syncAbilityAction(forDelete ? "destroy" : "update"), ability)
@@ -819,20 +870,20 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
819
870
  /**
820
871
  * Runs authorized query.
821
872
  * @param {FrontendModelResourceAction} action - Ability action.
822
- * @returns {import("../database/query/model-class-query.js").default<TModelClass>} - Authorized query.
873
+ * @returns {import("../database/query/model-class-query.js").default<TDatabaseModelClass>} - Authorized query.
823
874
  */
824
875
  authorizedQuery(action) {
825
876
  // Narrows the controller query to this resource's model class.
826
- return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelAbilityAuthorizedQuery(action))
877
+ return /** @type {import("../database/query/model-class-query.js").default<TDatabaseModelClass>} */ (this.typedControllerInstance().frontendModelAbilityAuthorizedQuery(action))
827
878
  }
828
879
 
829
880
  /**
830
881
  * Runs index query.
831
882
  * @param {FrontendModelResourceIndexQueryOptions} [options] - Query options.
832
- * @returns {import("../database/query/model-class-query.js").default<TModelClass>} - Frontend-model index query.
883
+ * @returns {import("../database/query/model-class-query.js").default<TDatabaseModelClass>} - Frontend-model index query.
833
884
  */
834
885
  indexQuery(options = {}) {
835
- return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelIndexQuery({
886
+ return /** @type {import("../database/query/model-class-query.js").default<TDatabaseModelClass>} */ (this.typedControllerInstance().frontendModelIndexQuery({
836
887
  ...options,
837
888
  resource: this
838
889
  }))
@@ -961,8 +1012,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
961
1012
  const normalizedAttributes = await this.normalizeCreateAttributes(attributes, options)
962
1013
  const attachmentSplit = this._extractAttachmentAttributes(normalizedAttributes, options.attachments ?? null)
963
1014
  const permit = parsePermittedParams(this.permittedParams({action: "create", ability: this.ability, locals: this.locals, params: normalizedAttributes}))
964
- const ModelClass = this.modelClass()
965
- const filtered = filterWritableFrontendModelAttributes(this.modelClass().prototype, ModelClass, attachmentSplit.attributes, this, permit.attributes)
1015
+ const ModelClass = this.databaseModelClass()
1016
+ const filtered = filterWritableFrontendModelAttributes(ModelClass.prototype, ModelClass, attachmentSplit.attributes, this, permit.attributes)
966
1017
  const model = new ModelClass()
967
1018
 
968
1019
  return await this.runMutationTransaction({
@@ -1021,7 +1072,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
1021
1072
  * @returns {Promise<import("../database/record/index.js").default>} - Saved model.
1022
1073
  */
1023
1074
  async _saveWithNestedAttributes({filtered, model, options, permit}) {
1024
- await this.modelClass().transaction(async () => {
1075
+ await this.databaseModelClass().transaction(async () => {
1025
1076
  await this._assignWithVirtualSetters(model, filtered)
1026
1077
  this._assignAttachments(model, options.attachments ?? null, permit.attributes)
1027
1078
 
@@ -1079,7 +1130,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
1079
1130
  * @returns {{attributes: Record<string, ReturnType<typeof JSON.parse>>, attachments: Record<string, ReturnType<typeof JSON.parse>> | null}} Attributes with attachment keys removed and merged attachment payload.
1080
1131
  */
1081
1132
  _extractAttachmentAttributes(attributes, attachments) {
1082
- const attachmentDefinitions = this.modelClass().getAttachmentsMap()
1133
+ const attachmentDefinitions = this.modelClass().attachmentDefinitions()
1083
1134
  const attachmentNames = new Set(Object.keys(attachmentDefinitions))
1084
1135
 
1085
1136
  if (attachmentNames.size === 0) return {attributes, attachments}
@@ -1272,7 +1323,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
1272
1323
  throw new Error(`No frontend-model resource registered for child model '${targetModelClass.getModelName()}' under relationship '${relationshipName}'.`)
1273
1324
  }
1274
1325
 
1275
- const childResource = new childResourceConfig.resourceClass({
1326
+ const ChildResource = frontendModelResourceInternalConstructor(childResourceConfig.resourceClass)
1327
+ const childResource = new ChildResource({
1276
1328
  ability: this.ability,
1277
1329
  controller,
1278
1330
  context: this.context || {},
@@ -1934,10 +1986,12 @@ function prototypeOwnerForMethod(instance, methodName) {
1934
1986
 
1935
1987
  /**
1936
1988
  * Runs filter writable frontend model attributes.
1989
+ * @template {FrontendModelResourceModelClass} ResourceModelClass
1990
+ * @template {typeof import("../database/record/index.js").default} ResourceDatabaseModelClass
1937
1991
  * @param {Record<string, ReturnType<typeof JSON.parse>>} receiver - Model instance or prototype.
1938
1992
  * @param {WritableAttributeReceiverClass} receiverClass - Static helper owner for the receiver.
1939
1993
  * @param {Record<string, ReturnType<typeof JSON.parse>>} attributes - Incoming frontend-model attributes.
1940
- * @param {FrontendModelBaseResource | null} [resource] - Resource instance for virtual-setter detection.
1994
+ * @param {FrontendModelBaseResource<ResourceModelClass, ResourceDatabaseModelClass> | null} [resource] - Resource instance for virtual-setter detection.
1941
1995
  * @param {string[] | null} [permittedAttributeNames] - Optional explicit permit list. `null` falls back to setter-existence checks only.
1942
1996
  * @returns {Record<string, ReturnType<typeof JSON.parse>>} - Writable attributes only.
1943
1997
  */
@@ -1945,7 +1999,7 @@ function filterWritableFrontendModelAttributes(
1945
1999
  receiver,
1946
2000
  receiverClass,
1947
2001
  attributes,
1948
- resource = /** @type {FrontendModelBaseResource | null} */ (null),
2002
+ resource = /** @type {FrontendModelBaseResource<ResourceModelClass, ResourceDatabaseModelClass> | null} */ (null),
1949
2003
  permittedAttributeNames = null
1950
2004
  ) {
1951
2005
  // Frontend-model writes should fail fast when callers submit read-only or unknown attrs.
@@ -66,7 +66,7 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
66
66
  */
67
67
  /**
68
68
  * Defines this typedef.
69
- * @typedef {{type: "hasOne" | "hasMany"}} FrontendModelAttachmentDefinition
69
+ * @typedef {{sync?: import("../configuration-types.js").AttachmentSyncConfiguration, type: "hasOne" | "hasMany"}} FrontendModelAttachmentDefinition
70
70
  */
71
71
  /**
72
72
  * Defines frontend-model attribute metadata.
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import Controller from "../controller.js"
4
- import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
4
+ import FrontendModelBaseResource, {frontendModelResourceInternalConstructor} from "../frontend-model-resource/base-resource.js"
5
5
 
6
6
  /** Configurations whose sync.api routes have already been mounted. */
7
7
  const mountedConfigurations = new WeakSet()
@@ -41,9 +41,10 @@ export default class SyncApiController extends Controller {
41
41
  */
42
42
  syncResource(params) {
43
43
  const ResourceClass = this.syncResourceClass()
44
+ const ResourceConstructor = frontendModelResourceInternalConstructor(ResourceClass)
44
45
  const ability = this.currentAbility()
45
46
 
46
- return new ResourceClass({
47
+ return new ResourceConstructor({
47
48
  ability,
48
49
  controller: /** @type {import("../frontend-model-resource/base-resource.js").FrontendModelResourceController} */ (/** @type {unknown} */ (this)),
49
50
  context: {
@@ -137,7 +138,7 @@ export default class SyncApiController extends Controller {
137
138
  * @returns {import("../configuration-types.js").FrontendModelResourceClassType} Sync resource class.
138
139
  */
139
140
  missingSyncResourceClass() {
140
- return /** @type {typeof FrontendModelBaseResource} */ (/** @type {unknown} */ (this.raiseMissingSyncResourceClass()))
141
+ return /** @type {import("../configuration-types.js").FrontendModelResourceClassType} */ (/** @type {unknown} */ (this.raiseMissingSyncResourceClass()))
141
142
  }
142
143
 
143
144
  /** Raises a configuration error for subclasses that do not provide a resource. */
@@ -155,7 +156,7 @@ export default class SyncApiController extends Controller {
155
156
 
156
157
  if (!modelClass) throw new Error("Sync resource class must define static ModelClass")
157
158
 
158
- return modelClass
159
+ return /** @type {typeof import("../database/record/index.js").default} */ (/** @type {unknown} */ (modelClass))
159
160
  }
160
161
 
161
162
  /**
@@ -178,4 +179,3 @@ export default class SyncApiController extends Controller {
178
179
  })
179
180
  }
180
181
  }
181
-
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
4
+ import {frontendModelResourceInternalConstructor} from "../frontend-model-resource/base-resource.js"
4
5
  import {markServerApply} from "./sync-publish-suppression.js"
5
6
  import {resolveFrontendModelResourceClass} from "../frontend-models/resource-definition.js"
6
7
  import {resolveSyncConflict} from "./conflict-strategy.js"
@@ -567,7 +568,7 @@ export default class SyncEnvelopeReplayService {
567
568
  * @returns {Promise<import("../frontend-model-resource/base-resource.js").default>} Routed resource instance.
568
569
  */
569
570
  async buildReplayResource({actor, context, mutation, registration}) {
570
- const ResourceClass = registration.resourceClass
571
+ const ResourceClass = frontendModelResourceInternalConstructor(registration.resourceClass)
571
572
  const {ability, abilityContext} = await this.replayAbilityFor({actor, context})
572
573
 
573
574
  return new ResourceClass({