velocious 1.0.495 → 1.0.497

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 (91) hide show
  1. package/build/configuration-types.js +21 -0
  2. package/build/configuration.js +48 -0
  3. package/build/database/column-types.js +19 -0
  4. package/build/database/record/index.js +11 -0
  5. package/build/database/record/validation-messages.js +50 -0
  6. package/build/database/record/validators/length.js +52 -0
  7. package/build/database/record/validators/presence.js +4 -1
  8. package/build/database/record/validators/uniqueness.js +4 -1
  9. package/build/frontend-model-resource/base-resource.js +175 -5
  10. package/build/frontend-models/resource-definition.js +46 -0
  11. package/build/src/configuration-types.d.ts +68 -0
  12. package/build/src/configuration-types.d.ts.map +1 -1
  13. package/build/src/configuration-types.js +20 -1
  14. package/build/src/configuration.d.ts +6 -0
  15. package/build/src/configuration.d.ts.map +1 -1
  16. package/build/src/configuration.js +45 -1
  17. package/build/src/database/column-types.d.ts +8 -0
  18. package/build/src/database/column-types.d.ts.map +1 -0
  19. package/build/src/database/column-types.js +18 -0
  20. package/build/src/database/record/index.d.ts +8 -0
  21. package/build/src/database/record/index.d.ts.map +1 -1
  22. package/build/src/database/record/index.js +11 -1
  23. package/build/src/database/record/validation-messages.d.ts +38 -0
  24. package/build/src/database/record/validation-messages.d.ts.map +1 -0
  25. package/build/src/database/record/validation-messages.js +45 -0
  26. package/build/src/database/record/validators/length.d.ts +12 -0
  27. package/build/src/database/record/validators/length.d.ts.map +1 -0
  28. package/build/src/database/record/validators/length.js +45 -0
  29. package/build/src/database/record/validators/presence.d.ts.map +1 -1
  30. package/build/src/database/record/validators/presence.js +4 -2
  31. package/build/src/database/record/validators/uniqueness.d.ts.map +1 -1
  32. package/build/src/database/record/validators/uniqueness.js +4 -2
  33. package/build/src/frontend-model-resource/base-resource.d.ts +184 -5
  34. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  35. package/build/src/frontend-model-resource/base-resource.js +155 -6
  36. package/build/src/frontend-models/resource-definition.d.ts +41 -0
  37. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  38. package/build/src/frontend-models/resource-definition.js +42 -1
  39. package/build/src/sync/sync-api-client.d.ts +4 -1
  40. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  41. package/build/src/sync/sync-api-client.js +9 -2
  42. package/build/src/sync/sync-client-types.d.ts +88 -10
  43. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  44. package/build/src/sync/sync-client-types.js +1 -1
  45. package/build/src/sync/sync-client.d.ts +38 -11
  46. package/build/src/sync/sync-client.d.ts.map +1 -1
  47. package/build/src/sync/sync-client.js +193 -29
  48. package/build/src/sync/sync-envelope-replay-service.d.ts +166 -3
  49. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  50. package/build/src/sync/sync-envelope-replay-service.js +304 -12
  51. package/build/src/sync/sync-replay-upsert-applier.d.ts +1 -0
  52. package/build/src/sync/sync-replay-upsert-applier.d.ts.map +1 -1
  53. package/build/src/sync/sync-replay-upsert-applier.js +2 -1
  54. package/build/src/sync/sync-resource-base.d.ts +16 -32
  55. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  56. package/build/src/sync/sync-resource-base.js +31 -43
  57. package/build/src/testing/test-runner.d.ts +12 -9
  58. package/build/src/testing/test-runner.d.ts.map +1 -1
  59. package/build/src/testing/test-runner.js +30 -9
  60. package/build/sync/sync-api-client.js +7 -1
  61. package/build/sync/sync-client-types.js +34 -5
  62. package/build/sync/sync-client.js +223 -27
  63. package/build/sync/sync-envelope-replay-service.js +350 -10
  64. package/build/sync/sync-replay-upsert-applier.js +1 -0
  65. package/build/sync/sync-resource-base.js +32 -45
  66. package/build/testing/test-runner.js +30 -8
  67. package/build/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +1 -1
  69. package/scripts/run-tests.js +6 -0
  70. package/src/configuration-types.js +21 -0
  71. package/src/configuration.js +48 -0
  72. package/src/database/column-types.js +19 -0
  73. package/src/database/record/index.js +11 -0
  74. package/src/database/record/validation-messages.js +50 -0
  75. package/src/database/record/validators/length.js +52 -0
  76. package/src/database/record/validators/presence.js +4 -1
  77. package/src/database/record/validators/uniqueness.js +4 -1
  78. package/src/frontend-model-resource/base-resource.js +175 -5
  79. package/src/frontend-models/resource-definition.js +46 -0
  80. package/src/sync/sync-api-client.js +7 -1
  81. package/src/sync/sync-client-types.js +34 -5
  82. package/src/sync/sync-client.js +223 -27
  83. package/src/sync/sync-envelope-replay-service.js +350 -10
  84. package/src/sync/sync-replay-upsert-applier.js +1 -0
  85. package/src/sync/sync-resource-base.js +32 -45
  86. package/src/testing/test-runner.js +30 -8
  87. package/build/src/sync/sync-attribute-normalizer.d.ts +0 -105
  88. package/build/src/sync/sync-attribute-normalizer.d.ts.map +0 -1
  89. package/build/src/sync/sync-attribute-normalizer.js +0 -172
  90. package/build/sync/sync-attribute-normalizer.js +0 -186
  91. package/src/sync/sync-attribute-normalizer.js +0 -186
@@ -362,10 +362,31 @@
362
362
  * @property {FrontendModelResourceClassType} resourceClass - App sync resource class served by the auto-mounted sync endpoints.
363
363
  */
364
364
 
365
+ /**
366
+ * Client-side sync transport owning HTTP POSTs to the framework sync endpoints,
367
+ * matching the frontend-model websocket client post contract.
368
+ * @typedef {object} VelociousSyncClientTransport
369
+ * @property {(path: string, body?: ?, options?: {headers?: Record<string, string>}) => Promise<{json: () => ?}>} post - Posts one request and resolves a response with a json accessor.
370
+ */
371
+
372
+ /**
373
+ * Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
374
+ * The framework owns the `${mountPath}/changes` and `${mountPath}/replay`
375
+ * POSTers over the given transport.
376
+ * @typedef {object} VelociousSyncClientConfiguration
377
+ * @property {() => string | Promise<string>} authenticationToken - Resolves the auth token sent with sync requests.
378
+ * @property {number} [batchSize] - Max syncs per request.
379
+ * @property {() => boolean | Promise<boolean>} [isOnline] - Connectivity gate for pulls and replays. Defaults to always online.
380
+ * @property {string} [mountPath] - Mount path the server serves the sync endpoints under (match the server's `sync.api.mountPath`). Defaults to "/velocious/sync"; normalization strips trailing slashes and always fills in the default.
381
+ * @property {(error: Error) => void} [onError] - Reports background replay/pull failures. Defaults to rethrowing.
382
+ * @property {VelociousSyncClientTransport} transport - Transport posting to the framework sync endpoints (e.g. the frontend-model websocket client).
383
+ */
384
+
365
385
  /**
366
386
  * Velocious sync configuration.
367
387
  * @typedef {object} VelociousSyncConfiguration
368
388
  * @property {VelociousSyncApiConfiguration} [api] - Auto-mounts the Velocious sync changes/replay endpoints for this resource class.
389
+ * @property {VelociousSyncClientConfiguration} [client] - Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
369
390
  * @property {import("./sync/device-identity.js").SyncJsonWebKey | null} [deviceCertificateBackendPublicKey] - Public backend key used to verify offline device certificates for sync replay.
370
391
  * @property {number} [changeFeedRetentionSize] - Number of accepted server changes retained before clients must refresh from snapshot.
371
392
  * @property {Array<import("./sync/offline-grant.js").OfflineGrantSigningKey>} offlineGrantSigningKeys - Signing keys used to issue and verify offline grants. Secrets must never be exposed to clients.
@@ -444,12 +444,60 @@ export default class VelociousConfiguration {
444
444
  return {
445
445
  api: this._normalizeSyncApiConfiguration(api),
446
446
  changeFeedRetentionSize: changeFeedRetentionSize || 10000,
447
+ client: this._normalizeSyncClientConfiguration(sync?.client),
447
448
  deviceCertificateBackendPublicKey,
448
449
  offlineGrantSigningKeys: offlineGrantSigningKeys.map((key) => normalizeOfflineGrantSigningKey(key)),
449
450
  offlineGrantTtlMs: offlineGrantTtlMs || 24 * 60 * 60 * 1000
450
451
  }
451
452
  }
452
453
 
454
+ /**
455
+ * Normalizes client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
456
+ * @param {import("./configuration-types.js").VelociousSyncClientConfiguration | undefined} client - Client-side sync configuration.
457
+ * @returns {import("./configuration-types.js").VelociousSyncClientConfiguration | undefined} - Normalized client-side sync configuration.
458
+ */
459
+ _normalizeSyncClientConfiguration(client) {
460
+ if (client === undefined || client === null) return undefined
461
+
462
+ if (typeof client !== "object" || Array.isArray(client)) {
463
+ throw new Error("sync.client must be an object with transport and authenticationToken")
464
+ }
465
+
466
+ const {authenticationToken, batchSize, isOnline, mountPath, onError, transport, ...restClient} = client
467
+ const restClientKeys = Object.keys(restClient)
468
+
469
+ if (restClientKeys.length > 0) {
470
+ throw new Error(`sync.client received unknown keys: ${restClientKeys.join(", ")} (supported: authenticationToken, batchSize, isOnline, mountPath, onError, transport)`)
471
+ }
472
+ if (!transport || typeof transport !== "object" || typeof transport.post !== "function") {
473
+ throw new Error("sync.client.transport must be an object with a post(path, body) method (like the frontend-model websocket client)")
474
+ }
475
+ if (typeof authenticationToken !== "function") {
476
+ throw new Error("sync.client.authenticationToken must be a function resolving the auth token sent with sync requests")
477
+ }
478
+ if (isOnline !== undefined && typeof isOnline !== "function") {
479
+ throw new Error("sync.client.isOnline must be a function resolving connectivity")
480
+ }
481
+ if (onError !== undefined && typeof onError !== "function") {
482
+ throw new Error("sync.client.onError must be a function reporting background sync failures")
483
+ }
484
+ if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize <= 0)) {
485
+ throw new Error("sync.client.batchSize must be a positive integer")
486
+ }
487
+ if (mountPath !== undefined && (typeof mountPath !== "string" || !mountPath.startsWith("/"))) {
488
+ throw new Error(`sync.client.mountPath must start with '/', got: ${String(mountPath)}`)
489
+ }
490
+
491
+ return {
492
+ authenticationToken,
493
+ batchSize,
494
+ isOnline,
495
+ mountPath: (mountPath || "/velocious/sync").replace(/\/+$/u, "") || "/",
496
+ onError,
497
+ transport
498
+ }
499
+ }
500
+
453
501
  /**
454
502
  * Normalizes sync API endpoint configuration.
455
503
  * @param {import("./configuration-types.js").VelociousSyncApiConfiguration | undefined} api - Sync API configuration.
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Driver column types that store boolean values. Drivers report storage types,
5
+ * which diverge for booleans (Postgres/SQLite `boolean`, some drivers `bool`,
6
+ * MSSQL `bit`), so cross-driver consumers must match them uniformly.
7
+ * @type {Set<string>}
8
+ */
9
+ const BOOLEAN_COLUMN_TYPES = new Set(["bit", "bool", "boolean"])
10
+
11
+ /**
12
+ * Whether a driver-reported (or cast) column type stores boolean values,
13
+ * uniformly across database drivers.
14
+ * @param {string} columnType - Driver-reported (or cast) column type.
15
+ * @returns {boolean} Whether the column type is boolean-backed.
16
+ */
17
+ export function isBooleanColumnType(columnType) {
18
+ return BOOLEAN_COLUMN_TYPES.has(columnType.toLowerCase())
19
+ }
@@ -48,6 +48,7 @@ import { normalizeDateStringForWrite, normalizeDateValueForRead, normalizeDateVa
48
48
  import {formatValue} from "../../utils/format-value.js"
49
49
  import {captureCreateAuditChanges, captureUpdateAuditChanges, createAudit, createCreateAudit, createDestroyAudit, createUpdateAudit, registerAuditCallback, registerAuditing, withoutAudit} from "./auditing.js"
50
50
  import ValidatorsFormat from "./validators/format.js"
51
+ import ValidatorsLength from "./validators/length.js"
51
52
  import ValidatorsPresence from "./validators/presence.js"
52
53
  import ValidatorsUniqueness from "./validators/uniqueness.js"
53
54
  import registerActsAsListCallbacks from "./acts-as-list.js"
@@ -238,6 +239,15 @@ class VelociousDatabaseRecord {
238
239
  * @type {string | undefined} */
239
240
  static modelName
240
241
 
242
+ /**
243
+ * Opt-in client sync declaration consumed by `SyncClient.fromConfiguration(...)`.
244
+ * Declare `static sync = true` (all defaults) or a declaration object like
245
+ * `static sync = {track: ["create", "update"], syncType: "upsert"}` to have the
246
+ * sync client auto-discover this model and derive its resource config from
247
+ * column metadata.
248
+ * @type {import("../../sync/sync-client-types.js").ModelSyncDeclaration | undefined} */
249
+ static sync
250
+
241
251
  /**
242
252
  * Narrows the runtime value to the documented type.
243
253
  * @type {Promise<void> | null | undefined} */
@@ -4403,6 +4413,7 @@ class VelociousDatabaseRecord {
4403
4413
  */
4404
4414
 
4405
4415
  VelociousDatabaseRecord.registerValidatorType("format", ValidatorsFormat)
4416
+ VelociousDatabaseRecord.registerValidatorType("length", ValidatorsLength)
4406
4417
  VelociousDatabaseRecord.registerValidatorType("presence", ValidatorsPresence)
4407
4418
  VelociousDatabaseRecord.registerValidatorType("uniqueness", ValidatorsUniqueness)
4408
4419
 
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Translates a validation message ID into a localized message.
5
+ * @callback ValidationMessageTranslator
6
+ * @param {string} msgID - Stable message ID (e.g. "velocious.errors.messages.blank").
7
+ * @param {Record<string, string | number> & {defaultValue?: string}} [args] - Interpolation variables plus the English default message.
8
+ * @returns {string} Localized message.
9
+ */
10
+
11
+ /**
12
+ * English default validation message predicates keyed by message type,
13
+ * matching Rails' `errors.messages.*` where an equivalent exists. Values may
14
+ * contain `%{variable}` placeholders.
15
+ * @type {Record<string, string>}
16
+ */
17
+ export const VALIDATION_MESSAGE_DEFAULTS = {
18
+ blank: "can't be blank",
19
+ taken: "has already been taken",
20
+ too_long: "is too long (maximum is %{count} characters)",
21
+ too_short: "is too short (minimum is %{count} characters)"
22
+ }
23
+
24
+ /**
25
+ * Builds a validation message predicate through the framework's translation
26
+ * layer. The message is looked up under `velocious.errors.messages.<type>`
27
+ * with the English default as fallback; `%{variable}` placeholders are
28
+ * interpolated from the given variables. Without a translator the English
29
+ * default is interpolated directly.
30
+ * @param {object} args - Options.
31
+ * @param {ValidationMessageTranslator | null} [args.translator] - Translator resolving message IDs (usually `configuration.getTranslator()`).
32
+ * @param {string} args.type - Message type key in {@link VALIDATION_MESSAGE_DEFAULTS}.
33
+ * @param {Record<string, string | number>} [args.variables] - Interpolation variables.
34
+ * @returns {string} Localized message predicate.
35
+ */
36
+ export default function validationMessage({translator, type, variables}) {
37
+ const defaultMessage = VALIDATION_MESSAGE_DEFAULTS[type]
38
+
39
+ if (!defaultMessage) throw new Error(`Unknown validation message type: ${String(type)}`)
40
+
41
+ if (translator) return translator(`velocious.errors.messages.${type}`, {...variables, defaultValue: defaultMessage})
42
+
43
+ let message = defaultMessage
44
+
45
+ for (const [variableName, variableValue] of Object.entries(variables ?? {})) {
46
+ message = message.replaceAll(`%{${variableName}}`, String(variableValue))
47
+ }
48
+
49
+ return message
50
+ }
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+
3
+ import Base from "./base.js"
4
+ import validationMessage from "../validation-messages.js"
5
+
6
+ export default class VelociousDatabaseRecordValidatorsLength extends Base {
7
+ /**
8
+ * Runs validate: bounds the value's string length by the `maximum` and/or
9
+ * `minimum` options. Absent values (null/undefined/"") are skipped — they
10
+ * are the presence validator's concern.
11
+ * @param {object} args - Options object.
12
+ * @param {import("../index.js").default} args.model - Model instance.
13
+ * @param {string} args.attributeName - Attribute name.
14
+ * @returns {Promise<void>} - Resolves when complete.
15
+ */
16
+ async validate({model, attributeName}) {
17
+ const maximum = this.args?.maximum
18
+ const minimum = this.args?.minimum
19
+
20
+ if (typeof maximum != "number" && typeof minimum != "number") {
21
+ throw new Error("length validator requires a maximum and/or minimum option")
22
+ }
23
+
24
+ const rawValue = model.readAttribute(attributeName)
25
+
26
+ if (rawValue === null || rawValue === undefined || rawValue === "") return
27
+
28
+ const valueLength = String(rawValue).length
29
+ const translator = model.getModelClass()._getConfiguration().getTranslator()
30
+
31
+ if (typeof maximum == "number" && valueLength > maximum) {
32
+ this._addError(model, attributeName, validationMessage({translator, type: "too_long", variables: {count: maximum}}))
33
+ }
34
+
35
+ if (typeof minimum == "number" && valueLength < minimum) {
36
+ this._addError(model, attributeName, validationMessage({translator, type: "too_short", variables: {count: minimum}}))
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Adds a length validation error to the model.
42
+ * @param {import("../index.js").default} model - Model instance.
43
+ * @param {string} attributeName - Attribute name.
44
+ * @param {string} message - Translated message predicate.
45
+ * @returns {void}
46
+ */
47
+ _addError(model, attributeName, message) {
48
+ if (!(attributeName in model._validationErrors)) model._validationErrors[attributeName] = []
49
+
50
+ model._validationErrors[attributeName].push({type: "length", message})
51
+ }
52
+ }
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import Base from "./base.js"
4
+ import validationMessage from "../validation-messages.js"
4
5
 
5
6
  export default class VelociousDatabaseRecordValidatorsPresence extends Base {
6
7
  /**
@@ -16,7 +17,9 @@ export default class VelociousDatabaseRecordValidatorsPresence extends Base {
16
17
  if (!attributeValue) {
17
18
  if (!(attributeName in model._validationErrors)) model._validationErrors[attributeName] = []
18
19
 
19
- model._validationErrors[attributeName].push({type: "presence", message: "can't be blank"})
20
+ const translator = model.getModelClass()._getConfiguration().getTranslator()
21
+
22
+ model._validationErrors[attributeName].push({type: "presence", message: validationMessage({translator, type: "blank"})})
20
23
  }
21
24
  }
22
25
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import Base from "./base.js"
4
4
  import * as inflection from "inflection"
5
+ import validationMessage from "../validation-messages.js"
5
6
 
6
7
  export default class VelociousDatabaseRecordValidatorsUniqueness extends Base {
7
8
  /**
@@ -61,7 +62,9 @@ export default class VelociousDatabaseRecordValidatorsUniqueness extends Base {
61
62
  if (existingRecord) {
62
63
  if (!(attributeName in model._validationErrors)) model._validationErrors[attributeName] = []
63
64
 
64
- model._validationErrors[attributeName].push({type: "uniqueness", message: "has already been taken"})
65
+ const translator = modelClass._getConfiguration().getTranslator()
66
+
67
+ model._validationErrors[attributeName].push({type: "uniqueness", message: validationMessage({translator, type: "taken"})})
65
68
  }
66
69
  }
67
70
 
@@ -3,6 +3,7 @@
3
3
  import AuthorizationBaseResource from "../authorization/base-resource.js"
4
4
  import * as inflection from "inflection"
5
5
  import isPlainObject from "../utils/plain-object.js"
6
+ import VelociousError from "../velocious-error.js"
6
7
 
7
8
  /**
8
9
  * Built-in frontend-model resource action.
@@ -87,6 +88,34 @@ import isPlainObject from "../utils/plain-object.js"
87
88
  * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration | import("../configuration-types.js").FrontendModelResourceConfiguration} [resourceConfiguration] - Optional normalized resource configuration.
88
89
  */
89
90
 
91
+ /**
92
+ * Normalized sync replay mutation passed to the resource sync hooks.
93
+ * @typedef {import("../sync/sync-envelope-replay-service.js").SyncReplayMutation} FrontendModelSyncMutation
94
+ */
95
+
96
+ /**
97
+ * Sync mutation authorization result.
98
+ * @typedef {object} FrontendModelSyncAuthorization
99
+ * @property {boolean} allowed - Whether the mutation may be applied.
100
+ * @property {string} [reason] - Stable failure reason code when denied.
101
+ */
102
+
103
+ /**
104
+ * Arguments for the applySync full-escape-hatch hook.
105
+ * @typedef {object} FrontendModelApplySyncArgs
106
+ * @property {Record<string, ?>} context - Replay context.
107
+ * @property {import("../database/record/index.js").default | null} existingSync - Existing sync row or null.
108
+ * @property {FrontendModelSyncMutation} mutation - Normalized replay mutation.
109
+ */
110
+
111
+ /**
112
+ * Apply result produced by routed sync mutation application.
113
+ * @typedef {object} FrontendModelSyncApplyResult
114
+ * @property {boolean} created - Whether a record was created.
115
+ * @property {boolean} [deleted] - Whether a record was deleted.
116
+ * @property {import("../database/record/index.js").default | null} record - Applied record or null.
117
+ */
118
+
90
119
  /**
91
120
  * Resolved frontend-model resource registration.
92
121
  * @typedef {object} FrontendModelResolvedResourceConfiguration
@@ -172,6 +201,16 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
172
201
  /** @type {?} */
173
202
  static SharedResource = undefined
174
203
 
204
+ /**
205
+ * Declarative writable-attribute permit list (camelCase attribute names)
206
+ * used as the default {@link FrontendModelBaseResource#permittedParams} and
207
+ * as the routed sync replay permit. Resolved through the shared resource
208
+ * like the other static resource config: an undeclared environment list
209
+ * falls back to the shared resource's list, while an explicit declaration
210
+ * (including `null`) wins.
211
+ * @type {string[] | null | undefined} */
212
+ static writableAttributes = undefined
213
+
175
214
  /**
176
215
  * Runs constructor.
177
216
  * @param {FrontendModelResourceAbilityArgs | FrontendModelResourceControllerArgs} args - Resource args.
@@ -206,7 +245,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
206
245
  /**
207
246
  * Reads a static resource config value from the environment resource first,
208
247
  * then from the shared resource.
209
- * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "relationships" | "server" | "sync" | "translatedAttributes"} name - Static config property name.
248
+ * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "relationships" | "server" | "sync" | "translatedAttributes" | "writableAttributes"} name - Static config property name.
210
249
  * @returns {?} - Resolved config value.
211
250
  */
212
251
  static sharedResourceStaticValue(name) {
@@ -481,9 +520,10 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
481
520
  * }
482
521
  * }
483
522
  *
484
- * Default implementation returns `[]` — nothing permitted. Subclasses
485
- * must override to enable writes. A resource that does not declare
486
- * `permittedParams` cannot accept any write.
523
+ * Default implementation returns the declared
524
+ * {@link FrontendModelBaseResource.writableAttributes} permit list, or `[]`
525
+ * nothing permitted without a declared list. Subclasses override to
526
+ * customize; an explicit override always wins.
487
527
  * @param {{action?: "create" | "update", params?: Record<string, ?>, ability?: import("../authorization/ability.js").default, locals?: Record<string, ?>}} [arg] - Request context.
488
528
  * @returns {Array<string | Record<string, ?>>} - Permit spec.
489
529
  */
@@ -491,10 +531,140 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
491
531
  return this.sharedResourceMethodOr("permittedParams", [arg], () => {
492
532
  void arg
493
533
 
494
- return []
534
+ return this.declaredWritableAttributes() ?? []
495
535
  })
496
536
  }
497
537
 
538
+ /**
539
+ * Resolves the declared writable-attribute permit list from the environment
540
+ * resource first, then the shared resource — mirroring how the other
541
+ * static resource config resolves. An explicit environment declaration
542
+ * (including `null`) wins over the shared resource's list.
543
+ * @returns {string[] | null} Declared permit list or null when undeclared.
544
+ */
545
+ declaredWritableAttributes() {
546
+ const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
547
+ const permittedAttributes = /** @type {string[] | null | undefined} */ (ResourceClass.sharedResourceStaticValue("writableAttributes"))
548
+
549
+ return permittedAttributes ?? null
550
+ }
551
+
552
+ /**
553
+ * Builds the client-safe error thrown for a failed writable-attribute validation.
554
+ * @param {string} message - Human-readable validation message.
555
+ * @param {{cause?: Error, code: string}} details - Stable machine-readable code and optional cause.
556
+ * @returns {Error} Client-safe error.
557
+ */
558
+ writableAttributeError(message, {cause, code}) {
559
+ return VelociousError.safe(message, cause ? {cause, code} : {code})
560
+ }
561
+
562
+ /**
563
+ * Authorizes one routed sync replay mutation before it is applied.
564
+ * Defaults to allowing every mutation; record-level authorization still
565
+ * applies through {@link FrontendModelBaseResource#findSyncRecord} scoping
566
+ * and the create membership check.
567
+ * @param {object} args - Options.
568
+ * @param {Record<string, ?>} args.context - Replay context.
569
+ * @param {FrontendModelSyncMutation} args.mutation - Normalized replay mutation.
570
+ * @returns {FrontendModelSyncAuthorization | Promise<FrontendModelSyncAuthorization>} Authorization result.
571
+ */
572
+ authorizeSyncMutation({context, mutation}) {
573
+ void context
574
+ void mutation
575
+
576
+ return {allowed: true}
577
+ }
578
+
579
+ /**
580
+ * Returns the per-sync failure reason reported when a routed sync mutation
581
+ * fails record-level authorization. Defaults to null, which reports the
582
+ * generic "access-denied" reason.
583
+ * @param {object} args - Options.
584
+ * @param {"create" | "destroy" | "update"} args.action - Denied action.
585
+ * @param {FrontendModelSyncMutation} args.mutation - Normalized replay mutation.
586
+ * @returns {string | null} Stable failure reason code or null for the generic default.
587
+ */
588
+ syncAuthorizationFailureReason({action, mutation}) {
589
+ void action
590
+ void mutation
591
+
592
+ return null
593
+ }
594
+
595
+ /**
596
+ * Finds the existing record targeted by a routed sync replay mutation.
597
+ * Defaults to an `accessibleFor` lookup by primary key through the
598
+ * resource's normalized ability action for update (or destroy for delete
599
+ * mutations), falling back to an unscoped lookup without an ability.
600
+ * @param {object} args - Options.
601
+ * @param {import("../authorization/ability.js").default} [args.ability] - Ability override. Defaults to the resource ability.
602
+ * @param {boolean} [args.forDelete] - Whether the lookup is for a delete mutation.
603
+ * @param {FrontendModelSyncMutation} args.mutation - Normalized replay mutation.
604
+ * @returns {Promise<import("../database/record/index.js").default | null>} Existing record or null.
605
+ */
606
+ async findSyncRecord({ability = this.ability, forDelete = false, mutation}) {
607
+ const ModelClass = this.modelClass()
608
+ const primaryKey = ModelClass.primaryKey()
609
+ const query = ability
610
+ ? ModelClass.accessibleFor(this.syncAbilityAction(forDelete ? "destroy" : "update"), ability)
611
+ : ModelClass.where({})
612
+
613
+ return await query.findBy({[primaryKey]: mutation.resourceId})
614
+ }
615
+
616
+ /**
617
+ * Maps a raw sync action to the resource's normalized ability action when
618
+ * the resource configuration declares an abilities mapping, otherwise the
619
+ * raw action name is used directly.
620
+ * @param {"create" | "destroy" | "update"} action - Raw sync action.
621
+ * @returns {string} Ability action.
622
+ */
623
+ syncAbilityAction(action) {
624
+ const abilities = this.resourceConfigurationValue?.abilities
625
+
626
+ if (abilities && typeof abilities == "object" && !Array.isArray(abilities)) {
627
+ const abilityAction = /** @type {Record<string, ?>} */ (abilities)[action]
628
+
629
+ if (typeof abilityAction == "string" && abilityAction.length > 0) return abilityAction
630
+ }
631
+
632
+ return action
633
+ }
634
+
635
+ /**
636
+ * Full escape hatch for routed sync mutation application. Returning a
637
+ * non-null result replaces the whole default apply flow (authorization,
638
+ * record lookup, normalization and save) with the returned apply result.
639
+ * @param {FrontendModelApplySyncArgs} args - Apply args.
640
+ * @returns {FrontendModelSyncApplyResult | null | Promise<FrontendModelSyncApplyResult | null>} Apply result or null for the default flow.
641
+ */
642
+ applySync(args) {
643
+ void args
644
+
645
+ return null
646
+ }
647
+
648
+ /**
649
+ * Runs after a routed sync mutation was applied. Returned entries are
650
+ * merged into the apply result, reaching persistExtraAttributes and
651
+ * broadcasts.
652
+ * @param {object} args - Options.
653
+ * @param {Record<string, ?>} args.context - Replay context.
654
+ * @param {boolean} args.created - Whether the record was created.
655
+ * @param {FrontendModelSyncMutation} args.mutation - Normalized replay mutation.
656
+ * @param {import("../database/record/index.js").default | null} args.record - Applied record or null.
657
+ * @returns {Record<string, ?> | Promise<Record<string, ?>>} Extra apply-result entries.
658
+ */
659
+ afterSyncApply({context, created, mutation, record}) {
660
+ void context
661
+ void created
662
+ void mutation
663
+ void record
664
+
665
+ return {}
666
+ }
667
+
498
668
  /**
499
669
  * Normalizes create attributes before permission filtering and saving.
500
670
  * @param {FrontendModelResourceAttributePayload} attributes - Incoming create attributes.
@@ -42,6 +42,7 @@ const RESOURCE_STATIC_CONFIG_KEYS = new Set([
42
42
  "primaryKey",
43
43
  "quickSearchColumns",
44
44
  "relationships",
45
+ "ReplayServiceClass",
45
46
  "server",
46
47
  "SharedResource",
47
48
  "sync",
@@ -881,3 +882,48 @@ function normalizeFrontendModelResourcePathForMatch(path) {
881
882
 
882
883
  return withLeadingSlash
883
884
  }
885
+
886
+ /**
887
+ * Resolved frontend-model resource registration for a replay resource type.
888
+ * @typedef {object} FrontendModelResolvedResourceRegistration
889
+ * @property {string} modelName - Effective frontend model name (modelName override or registry key).
890
+ * @property {import("../configuration-types.js").FrontendModelResourceClassType} resourceClass - Registered resource class.
891
+ * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration} resourceConfiguration - Normalized resource configuration.
892
+ */
893
+
894
+ /**
895
+ * Resolves the registered frontend-model resource class for a resource type
896
+ * across all backend projects. A resource's effective name is its
897
+ * `modelName` override when declared, otherwise its registry key — matching
898
+ * {@link frontendModelSyncManifestForBackendProjects}. A registry key shadowed
899
+ * by a `modelName` override does not resolve.
900
+ * @param {object} args - Options.
901
+ * @param {{getBackendProjects: () => import("../configuration-types.js").BackendProjectConfiguration[]}} args.configuration - Configuration exposing the backend projects.
902
+ * @param {string} args.resourceType - Frontend model name to resolve.
903
+ * @returns {FrontendModelResolvedResourceRegistration | null} Resolved registration or null when the resource type is not registered.
904
+ */
905
+ export function resolveFrontendModelResourceClass({configuration, resourceType}) {
906
+ for (const backendProject of configuration.getBackendProjects()) {
907
+ const resources = frontendModelResourcesForBackendProject(backendProject)
908
+
909
+ for (const configuredModelName of Object.keys(resources)) {
910
+ const resourceDefinition = resources[configuredModelName]
911
+ const resourceClass = frontendModelResourceClassFromDefinition(resourceDefinition)
912
+
913
+ if (!resourceClass) continue
914
+
915
+ // Cheap direct-key mismatch skip: only normalize configurations for the
916
+ // matching key or when a modelName override could rename the resource.
917
+ if (configuredModelName !== resourceType && !resourceClass.sharedResourceStaticValue("modelName")) continue
918
+
919
+ const resourceConfiguration = frontendModelResourceConfigurationFromDefinition(resourceDefinition)
920
+
921
+ if (!resourceConfiguration) continue
922
+ if ((resourceConfiguration.modelName || configuredModelName) !== resourceType) continue
923
+
924
+ return {modelName: resourceType, resourceClass, resourceConfiguration}
925
+ }
926
+ }
927
+
928
+ return null
929
+ }
@@ -315,10 +315,29 @@
315
315
  * @property {string} [mountPath] - Mount path for the sync endpoints. Defaults to "/velocious/sync".
316
316
  * @property {FrontendModelResourceClassType} resourceClass - App sync resource class served by the auto-mounted sync endpoints.
317
317
  */
318
+ /**
319
+ * Client-side sync transport owning HTTP POSTs to the framework sync endpoints,
320
+ * matching the frontend-model websocket client post contract.
321
+ * @typedef {object} VelociousSyncClientTransport
322
+ * @property {(path: string, body?: ?, options?: {headers?: Record<string, string>}) => Promise<{json: () => ?}>} post - Posts one request and resolves a response with a json accessor.
323
+ */
324
+ /**
325
+ * Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
326
+ * The framework owns the `${mountPath}/changes` and `${mountPath}/replay`
327
+ * POSTers over the given transport.
328
+ * @typedef {object} VelociousSyncClientConfiguration
329
+ * @property {() => string | Promise<string>} authenticationToken - Resolves the auth token sent with sync requests.
330
+ * @property {number} [batchSize] - Max syncs per request.
331
+ * @property {() => boolean | Promise<boolean>} [isOnline] - Connectivity gate for pulls and replays. Defaults to always online.
332
+ * @property {string} [mountPath] - Mount path the server serves the sync endpoints under (match the server's `sync.api.mountPath`). Defaults to "/velocious/sync"; normalization strips trailing slashes and always fills in the default.
333
+ * @property {(error: Error) => void} [onError] - Reports background replay/pull failures. Defaults to rethrowing.
334
+ * @property {VelociousSyncClientTransport} transport - Transport posting to the framework sync endpoints (e.g. the frontend-model websocket client).
335
+ */
318
336
  /**
319
337
  * Velocious sync configuration.
320
338
  * @typedef {object} VelociousSyncConfiguration
321
339
  * @property {VelociousSyncApiConfiguration} [api] - Auto-mounts the Velocious sync changes/replay endpoints for this resource class.
340
+ * @property {VelociousSyncClientConfiguration} [client] - Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
322
341
  * @property {import("./sync/device-identity.js").SyncJsonWebKey | null} [deviceCertificateBackendPublicKey] - Public backend key used to verify offline device certificates for sync replay.
323
342
  * @property {number} [changeFeedRetentionSize] - Number of accepted server changes retained before clients must refresh from snapshot.
324
343
  * @property {Array<import("./sync/offline-grant.js").OfflineGrantSigningKey>} offlineGrantSigningKeys - Signing keys used to issue and verify offline grants. Secrets must never be exposed to clients.
@@ -1255,6 +1274,51 @@ export type VelociousSyncApiConfiguration = {
1255
1274
  */
1256
1275
  resourceClass: FrontendModelResourceClassType;
1257
1276
  };
1277
+ /**
1278
+ * Client-side sync transport owning HTTP POSTs to the framework sync endpoints,
1279
+ * matching the frontend-model websocket client post contract.
1280
+ */
1281
+ export type VelociousSyncClientTransport = {
1282
+ /**
1283
+ * - Posts one request and resolves a response with a json accessor.
1284
+ */
1285
+ post: (path: string, body?: unknown, options?: {
1286
+ headers?: Record<string, string>;
1287
+ }) => Promise<{
1288
+ json: () => unknown;
1289
+ }>;
1290
+ };
1291
+ /**
1292
+ * Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
1293
+ * The framework owns the `${mountPath}/changes` and `${mountPath}/replay`
1294
+ * POSTers over the given transport.
1295
+ */
1296
+ export type VelociousSyncClientConfiguration = {
1297
+ /**
1298
+ * - Resolves the auth token sent with sync requests.
1299
+ */
1300
+ authenticationToken: () => string | Promise<string>;
1301
+ /**
1302
+ * - Max syncs per request.
1303
+ */
1304
+ batchSize?: number | undefined;
1305
+ /**
1306
+ * - Connectivity gate for pulls and replays. Defaults to always online.
1307
+ */
1308
+ isOnline?: (() => boolean | Promise<boolean>) | undefined;
1309
+ /**
1310
+ * - Mount path the server serves the sync endpoints under (match the server's `sync.api.mountPath`). Defaults to "/velocious/sync"; normalization strips trailing slashes and always fills in the default.
1311
+ */
1312
+ mountPath?: string | undefined;
1313
+ /**
1314
+ * - Reports background replay/pull failures. Defaults to rethrowing.
1315
+ */
1316
+ onError?: ((error: Error) => void) | undefined;
1317
+ /**
1318
+ * - Transport posting to the framework sync endpoints (e.g. the frontend-model websocket client).
1319
+ */
1320
+ transport: VelociousSyncClientTransport;
1321
+ };
1258
1322
  /**
1259
1323
  * Velocious sync configuration.
1260
1324
  */
@@ -1263,6 +1327,10 @@ export type VelociousSyncConfiguration = {
1263
1327
  * - Auto-mounts the Velocious sync changes/replay endpoints for this resource class.
1264
1328
  */
1265
1329
  api?: VelociousSyncApiConfiguration | undefined;
1330
+ /**
1331
+ * - Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
1332
+ */
1333
+ client?: VelociousSyncClientConfiguration | undefined;
1266
1334
  /**
1267
1335
  * - Public backend key used to verify offline device certificates for sync replay.
1268
1336
  */