velocious 1.0.494 → 1.0.496

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/build/database/record/index.js +2 -0
  2. package/build/database/record/validation-messages.js +50 -0
  3. package/build/database/record/validators/length.js +52 -0
  4. package/build/database/record/validators/presence.js +4 -1
  5. package/build/database/record/validators/uniqueness.js +4 -1
  6. package/build/frontend-model-resource/base-resource.js +175 -5
  7. package/build/frontend-models/resource-definition.js +49 -1
  8. package/build/src/database/record/index.d.ts.map +1 -1
  9. package/build/src/database/record/index.js +3 -1
  10. package/build/src/database/record/validation-messages.d.ts +38 -0
  11. package/build/src/database/record/validation-messages.d.ts.map +1 -0
  12. package/build/src/database/record/validation-messages.js +45 -0
  13. package/build/src/database/record/validators/length.d.ts +12 -0
  14. package/build/src/database/record/validators/length.d.ts.map +1 -0
  15. package/build/src/database/record/validators/length.js +45 -0
  16. package/build/src/database/record/validators/presence.d.ts.map +1 -1
  17. package/build/src/database/record/validators/presence.js +4 -2
  18. package/build/src/database/record/validators/uniqueness.d.ts.map +1 -1
  19. package/build/src/database/record/validators/uniqueness.js +4 -2
  20. package/build/src/frontend-model-resource/base-resource.d.ts +184 -5
  21. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  22. package/build/src/frontend-model-resource/base-resource.js +155 -6
  23. package/build/src/frontend-models/resource-definition.d.ts +41 -0
  24. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  25. package/build/src/frontend-models/resource-definition.js +45 -2
  26. package/build/src/sync/sync-envelope-replay-service.d.ts +166 -3
  27. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  28. package/build/src/sync/sync-envelope-replay-service.js +304 -12
  29. package/build/src/sync/sync-replay-upsert-applier.d.ts +1 -0
  30. package/build/src/sync/sync-replay-upsert-applier.d.ts.map +1 -1
  31. package/build/src/sync/sync-replay-upsert-applier.js +2 -1
  32. package/build/src/sync/sync-resource-base.d.ts +16 -32
  33. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  34. package/build/src/sync/sync-resource-base.js +31 -43
  35. package/build/src/testing/test-runner.d.ts +24 -0
  36. package/build/src/testing/test-runner.d.ts.map +1 -1
  37. package/build/src/testing/test-runner.js +99 -9
  38. package/build/sync/sync-envelope-replay-service.js +350 -10
  39. package/build/sync/sync-replay-upsert-applier.js +1 -0
  40. package/build/sync/sync-resource-base.js +32 -45
  41. package/build/testing/test-runner.js +104 -8
  42. package/build/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/scripts/run-tests.js +6 -0
  45. package/src/database/record/index.js +2 -0
  46. package/src/database/record/validation-messages.js +50 -0
  47. package/src/database/record/validators/length.js +52 -0
  48. package/src/database/record/validators/presence.js +4 -1
  49. package/src/database/record/validators/uniqueness.js +4 -1
  50. package/src/frontend-model-resource/base-resource.js +175 -5
  51. package/src/frontend-models/resource-definition.js +49 -1
  52. package/src/sync/sync-envelope-replay-service.js +350 -10
  53. package/src/sync/sync-replay-upsert-applier.js +1 -0
  54. package/src/sync/sync-resource-base.js +32 -45
  55. package/src/testing/test-runner.js +104 -8
  56. package/build/src/sync/sync-attribute-normalizer.d.ts +0 -105
  57. package/build/src/sync/sync-attribute-normalizer.d.ts.map +0 -1
  58. package/build/src/sync/sync-attribute-normalizer.js +0 -172
  59. package/build/sync/sync-attribute-normalizer.js +0 -186
  60. package/src/sync/sync-attribute-normalizer.js +0 -186
@@ -1,6 +1,9 @@
1
1
  // @ts-check
2
2
 
3
+ import {resolveFrontendModelResourceClass} from "../frontend-models/resource-definition.js"
3
4
  import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
5
+ import {ValidationError} from "../database/record/index.js"
6
+ import VelociousError from "../velocious-error.js"
4
7
 
5
8
  /**
6
9
  * One declarative broadcast fanned out after a mutation applies.
@@ -37,11 +40,16 @@ export default class SyncEnvelopeReplayService {
37
40
  * @param {?} [args.authenticationTokenModel] - Token model enabling the default token-lookup authenticateReplay.
38
41
  * @param {string} [args.authenticationTokenColumn] - Token model column holding the token. Defaults to "token".
39
42
  * @param {string} [args.authenticationTokenParam] - Request param carrying the token. Defaults to "authenticationToken".
40
- * @param {Record<string, ((args: Record<string, ?>) => Promise<?>) | ConstructorParameters<typeof SyncReplayUpsertApplier>[0]>} [args.applyHandlers] - Per-resourceType apply handlers (functions or declarative upsert-applier specs) enabling the default applyReplayMutation dispatch.
43
+ * @param {Record<string, ((args: Record<string, ?>) => Promise<?>) | ConstructorParameters<typeof SyncReplayUpsertApplier>[0]>} [args.applyHandlers] - Per-resourceType apply handlers (functions or declarative upsert-applier specs) enabling the default applyReplayMutation dispatch. Deprecated: prefer resource routing via `configuration`/`resourceTypeOverrides`; applyHandlers remain for released adopters and will be removed after their migration.
41
44
  * @param {(args: Record<string, ?>) => Record<string, ?>} [args.persistExtraAttributes] - Extra attributes merged into the model-backed persisted row (e.g. an event scope column).
42
45
  * @param {(args: {mutation: ?, applyResult: ?}) => ?} [args.persistSerializedData] - Overrides the persisted data payload (object results are JSON stringified).
43
46
  * @param {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [args.broadcaster] - Delivers declarative broadcasts. Required when broadcasts are configured.
44
47
  * @param {SyncReplayBroadcast[]} [args.broadcasts] - Broadcasts fanned out by the default afterReplayMutation.
48
+ * @param {{getBackendProjects: () => import("../configuration-types.js").BackendProjectConfiguration[]}} [args.configuration] - Configuration whose frontend-model registry routes mutations to resource classes.
49
+ * @param {Record<string, import("../configuration-types.js").FrontendModelResourceClassType | string>} [args.resourceTypeOverrides] - Per-resourceType routing overrides: a resource class, or a string alias resolved through the registry.
50
+ * @param {import("../authorization/ability.js").default} [args.ability] - Ability scoping routed record lookups and create membership checks.
51
+ * @param {Record<string, ?>} [args.abilityContext] - Ability context passed to routed resources.
52
+ * @param {Record<string, ?>} [args.locals] - Locals passed to routed resources.
45
53
  */
46
54
  constructor(args = {}) {
47
55
  this.logger = args.logger || console
@@ -55,6 +63,13 @@ export default class SyncEnvelopeReplayService {
55
63
  this.broadcaster = args.broadcaster || null
56
64
  this.broadcasts = args.broadcasts || null
57
65
  this.applyHandlers = args.applyHandlers ? this.builtApplyHandlers(args.applyHandlers) : null
66
+ this.configuration = args.configuration || null
67
+ this.resourceTypeOverrides = args.resourceTypeOverrides || null
68
+ this.ability = args.ability || null
69
+ this.abilityContext = args.abilityContext || null
70
+ this.locals = args.locals || null
71
+ /** @type {Map<string, SyncReplayResourceRegistration | null>} */
72
+ this._replayResourceRegistrations = new Map()
58
73
 
59
74
  if (args.actorForeignKeyColumn !== undefined && (typeof args.actorForeignKeyColumn !== "string" || args.actorForeignKeyColumn.length < 1)) {
60
75
  throw new Error(`actorForeignKeyColumn must be a non-blank string, got: ${String(args.actorForeignKeyColumn)}`)
@@ -121,9 +136,30 @@ export default class SyncEnvelopeReplayService {
121
136
 
122
137
  const existingSync = await this.findExistingReplaySync({actor: actorResult.actor, context, mutation})
123
138
  const shouldApply = await this.shouldApplyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
124
- const applyResult = shouldApply
125
- ? await this.applyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
126
- : await this.skippedReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
139
+
140
+ /** @type {?} */
141
+ let applyResult
142
+
143
+ try {
144
+ applyResult = shouldApply
145
+ ? await this.applyReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
146
+ : await this.skippedReplayMutation({actor: actorResult.actor, context, existingSync, mutation})
147
+ } catch (error) {
148
+ // Client-safe apply failures (schema validation, model validation,
149
+ // authorization denials, unknown resource types) fail this sync and
150
+ // keep the batch going; unexpected errors keep propagating.
151
+ if (error instanceof VelociousError && error.safeToExpose) {
152
+ syncResponses.push({
153
+ id: mutation.id,
154
+ syncState: "failed",
155
+ reason: error.code || "apply-failed",
156
+ message: error.message
157
+ })
158
+ continue
159
+ }
160
+
161
+ throw error
162
+ }
127
163
 
128
164
  await this.persistReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
129
165
  await this.afterReplayMutation({actor: actorResult.actor, context, existingSync, applyResult, mutation, shouldApply})
@@ -322,19 +358,315 @@ export default class SyncEnvelopeReplayService {
322
358
  /**
323
359
  * Applies one normalized mutation to domain models.
324
360
  *
325
- * Defaults to dispatching through the configured apply-handler registry;
326
- * mutations without a registered handler fail loudly.
361
+ * Dispatches through the configured apply-handler registry first (compat
362
+ * precedence); mutations without a matching handler fall through to
363
+ * resource routing when a configuration or resourceTypeOverrides are
364
+ * configured, and otherwise fail loudly.
327
365
  * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and mutation.
328
366
  * @returns {Promise<?>} Project-specific apply result.
329
367
  */
330
368
  async applyReplayMutation(args) {
331
- if (!this.applyHandlers) return null
369
+ if (this.applyHandlers) {
370
+ const applyHandler = this.applyHandlers[args.mutation.resourceType]
371
+
372
+ if (applyHandler) return await applyHandler(args)
373
+ if (!this.routingConfigured()) throw new Error(`No sync apply handler registered for: ${args.mutation.resourceType}`)
374
+ }
375
+
376
+ if (this.routingConfigured()) return await this.applyRoutedReplayMutation(args)
377
+
378
+ return null
379
+ }
380
+
381
+ /**
382
+ * Returns whether resource routing is configured on this service.
383
+ * @returns {boolean} Whether mutations route to frontend-model resources.
384
+ */
385
+ routingConfigured() {
386
+ return Boolean(this.configuration || this.resourceTypeOverrides)
387
+ }
388
+
389
+ /**
390
+ * Resolves the routed resource registration for a resource type, memoized
391
+ * per replay service. Overrides win over the configuration registry; string
392
+ * overrides are aliases resolved through the registry.
393
+ * @param {string} resourceType - Mutation resource type.
394
+ * @returns {SyncReplayResourceRegistration | null} Resolved registration or null when unroutable.
395
+ */
396
+ replayResourceRegistration(resourceType) {
397
+ const memoizedRegistration = this._replayResourceRegistrations.get(resourceType)
398
+
399
+ if (memoizedRegistration !== undefined) return memoizedRegistration
400
+
401
+ const registration = this.resolveReplayResourceRegistration(resourceType)
402
+
403
+ this._replayResourceRegistrations.set(resourceType, registration)
404
+
405
+ return registration
406
+ }
407
+
408
+ /**
409
+ * Uncached routed-resource resolution behind {@link SyncEnvelopeReplayService#replayResourceRegistration}.
410
+ * @param {string} resourceType - Mutation resource type.
411
+ * @returns {SyncReplayResourceRegistration | null} Resolved registration or null when unroutable.
412
+ */
413
+ resolveReplayResourceRegistration(resourceType) {
414
+ const override = this.resourceTypeOverrides?.[resourceType]
415
+
416
+ if (override && typeof override !== "string") {
417
+ return {modelName: resourceType, resourceClass: override, resourceConfiguration: null}
418
+ }
419
+
420
+ const registryResourceType = typeof override === "string" ? override : resourceType
421
+
422
+ if (!this.configuration) return null
332
423
 
333
- const applyHandler = this.applyHandlers[args.mutation.resourceType]
424
+ const resolvedRegistration = resolveFrontendModelResourceClass({configuration: this.configuration, resourceType: registryResourceType})
334
425
 
335
- if (!applyHandler) throw new Error(`No sync apply handler registered for: ${args.mutation.resourceType}`)
426
+ if (!resolvedRegistration) return null
336
427
 
337
- return await applyHandler(args)
428
+ return {
429
+ modelName: resolvedRegistration.modelName,
430
+ resourceClass: resolvedRegistration.resourceClass,
431
+ resourceConfiguration: resolvedRegistration.resourceConfiguration
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Builds the routed resource instance handling one mutation.
437
+ * @param {object} args - Options.
438
+ * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
439
+ * @param {SyncReplayResourceRegistration} args.registration - Resolved resource registration.
440
+ * @returns {import("../frontend-model-resource/base-resource.js").default} Routed resource instance.
441
+ */
442
+ buildReplayResource({mutation, registration}) {
443
+ const ResourceClass = registration.resourceClass
444
+
445
+ return new ResourceClass({
446
+ ability: this.ability || undefined,
447
+ context: this.abilityContext || {},
448
+ locals: this.locals || {},
449
+ modelName: registration.modelName,
450
+ params: mutation.data,
451
+ ...(registration.resourceConfiguration ? {resourceConfiguration: registration.resourceConfiguration} : {})
452
+ })
453
+ }
454
+
455
+ /**
456
+ * Applies one mutation through its routed frontend-model resource:
457
+ * authorization, ability-scoped record lookup, schema normalization and
458
+ * assign/save for updates, save-then-check membership creates, destroys for
459
+ * deletes, and the resource's afterSyncApply tail. Client-safe failures
460
+ * throw safe errors that fail the single sync.
461
+ * @param {{actor: ?, context: Record<string, ?>, existingSync: ?, mutation: import("./sync-envelope-replay-service.js").SyncReplayMutation}} args - Actor, batch context, existing sync row, and mutation.
462
+ * @returns {Promise<Record<string, ?>>} Apply result with record, created/deleted flags, and afterSyncApply extras.
463
+ */
464
+ async applyRoutedReplayMutation({context, existingSync, mutation}) {
465
+ const registration = this.replayResourceRegistration(mutation.resourceType)
466
+
467
+ if (!registration) {
468
+ throw VelociousError.safe(`Unknown sync resource type: ${mutation.resourceType}.`, {code: "unknown-resource-type"})
469
+ }
470
+
471
+ const resource = this.buildReplayResource({mutation, registration})
472
+ const customApplyResult = await resource.applySync({context, existingSync, mutation})
473
+
474
+ if (customApplyResult !== null) return customApplyResult
475
+
476
+ const authorization = await resource.authorizeSyncMutation({context, mutation})
477
+
478
+ if (!authorization.allowed) {
479
+ throw VelociousError.safe(`Sync mutation denied for: ${mutation.resourceType}.`, {code: authorization.reason || "access-denied"})
480
+ }
481
+
482
+ if (mutation.syncType === "delete") return await this.applyRoutedReplayDelete({mutation, resource})
483
+
484
+ return await this.applyRoutedReplayUpsert({context, mutation, resource})
485
+ }
486
+
487
+ /**
488
+ * Applies a routed delete mutation.
489
+ * @param {object} args - Options.
490
+ * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
491
+ * @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
492
+ * @returns {Promise<Record<string, ?>>} Apply result with the deleted flag.
493
+ */
494
+ async applyRoutedReplayDelete({mutation, resource}) {
495
+ const record = await resource.findSyncRecord({forDelete: true, mutation})
496
+
497
+ if (!record) return {created: false, deleted: false, record: null}
498
+
499
+ await record.destroy()
500
+
501
+ return {created: false, deleted: true, record}
502
+ }
503
+
504
+ /**
505
+ * Applies a routed upsert mutation: permitted payload attributes are
506
+ * assigned and saved onto the found record (the record layer owns value
507
+ * casting and validation), and missing records are created with the
508
+ * client-generated primary key plus a save-then-check membership check.
509
+ * Model validation failures become client-safe per-sync failures carrying
510
+ * the translated validation message.
511
+ * @param {object} args - Options.
512
+ * @param {Record<string, ?>} args.context - Replay context.
513
+ * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
514
+ * @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
515
+ * @returns {Promise<Record<string, ?>>} Apply result with record, created flag, and afterSyncApply extras.
516
+ */
517
+ async applyRoutedReplayUpsert({context, mutation, resource}) {
518
+ const attributes = this.permittedRoutedAttributes({mutation, resource})
519
+ const existingRecord = await resource.findSyncRecord({mutation})
520
+
521
+ /** @type {import("../database/record/index.js").default | null} */
522
+ let record = existingRecord
523
+ let created = false
524
+
525
+ if (existingRecord) {
526
+ existingRecord.assign(attributes)
527
+ await this.saveRoutedReplayRecord(existingRecord)
528
+ } else {
529
+ record = await this.createRoutedReplayRecord({attributes, mutation, resource})
530
+ created = true
531
+ }
532
+
533
+ const extras = await resource.afterSyncApply({context, created, mutation, record})
534
+
535
+ return {created, deleted: false, record, ...extras}
536
+ }
537
+
538
+ /**
539
+ * Filters a routed mutation payload down to the resource's declared
540
+ * writable-attribute permit list. Accepted keys per permitted attribute are
541
+ * the camelCase attribute name plus the model's actual column name; unknown
542
+ * keys fail the sync loudly. The primary key is dropped when permitted
543
+ * (snapshot payloads) — the envelope's resourceId is the authoritative
544
+ * record identity, so a payload id can never retarget the row.
545
+ * @param {object} args - Options.
546
+ * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
547
+ * @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
548
+ * @returns {Record<string, ?>} Permitted attributes for record.assign.
549
+ */
550
+ permittedRoutedAttributes({mutation, resource}) {
551
+ const permittedAttributes = resource.declaredWritableAttributes()
552
+
553
+ if (!permittedAttributes) {
554
+ throw new Error(`${resource.constructor.name} must declare static writableAttributes to apply routed sync mutations for: ${mutation.resourceType}`)
555
+ }
556
+
557
+ const ModelClass = resource.modelClass()
558
+ const attributeNameToColumnName = ModelClass.getAttributeNameToColumnNameMap()
559
+
560
+ /** @type {Set<string>} */
561
+ const allowedKeys = new Set()
562
+
563
+ for (const attributeName of permittedAttributes) {
564
+ allowedKeys.add(attributeName)
565
+
566
+ const columnName = attributeNameToColumnName[attributeName]
567
+
568
+ if (columnName) allowedKeys.add(columnName)
569
+ }
570
+
571
+ const primaryKey = ModelClass.primaryKey()
572
+ const primaryKeyAttribute = ModelClass.getColumnNameToAttributeNameMap()[primaryKey]
573
+
574
+ /** @type {Record<string, ?>} */
575
+ const attributes = {}
576
+
577
+ for (const [key, value] of Object.entries(mutation.data)) {
578
+ if (!allowedKeys.has(key)) {
579
+ throw resource.writableAttributeError(`Unknown attribute: ${key}.`, {code: "sync-unknown-attribute"})
580
+ }
581
+
582
+ if (key === primaryKey || key === primaryKeyAttribute) continue
583
+
584
+ attributes[key] = value
585
+ }
586
+
587
+ return attributes
588
+ }
589
+
590
+ /**
591
+ * Creates the routed record with the client-generated primary key, then
592
+ * verifies create-scope membership when an ability is configured: records
593
+ * outside the ability's create scope are destroyed again and fail the sync
594
+ * with the resource-declared reason. A record that already exists outside
595
+ * the resource's lookup scope fails the sync as an authorization denial
596
+ * instead of colliding on the primary key.
597
+ * @param {object} args - Options.
598
+ * @param {Record<string, ?>} args.attributes - Permitted payload attributes.
599
+ * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
600
+ * @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
601
+ * @returns {Promise<import("../database/record/index.js").default>} Created record.
602
+ */
603
+ async createRoutedReplayRecord({attributes, mutation, resource}) {
604
+ const ModelClass = resource.modelClass()
605
+ const primaryKey = ModelClass.primaryKey()
606
+ const conflictingIds = await ModelClass.where({[primaryKey]: mutation.resourceId}).pluck(primaryKey)
607
+
608
+ if (conflictingIds.length > 0) {
609
+ throw VelociousError.safe(`Sync update denied for: ${mutation.resourceType}.`, {
610
+ code: resource.syncAuthorizationFailureReason({action: "update", mutation}) || "access-denied"
611
+ })
612
+ }
613
+
614
+ /** @type {import("../database/record/index.js").default} */
615
+ let record
616
+
617
+ try {
618
+ record = await ModelClass.create({[primaryKey]: mutation.resourceId, ...attributes})
619
+ } catch (error) {
620
+ throw this.routedReplaySaveError(error)
621
+ }
622
+
623
+ const ability = resource.ability
624
+
625
+ if (ability) {
626
+ const memberIds = await ModelClass
627
+ .accessibleFor(resource.syncAbilityAction("create"), ability)
628
+ .where({[primaryKey]: record.id()})
629
+ .pluck(primaryKey)
630
+
631
+ if (memberIds.length === 0) {
632
+ await record.destroy()
633
+
634
+ throw VelociousError.safe(`Sync create denied for: ${mutation.resourceType}.`, {
635
+ code: resource.syncAuthorizationFailureReason({action: "create", mutation}) || "access-denied"
636
+ })
637
+ }
638
+ }
639
+
640
+ return record
641
+ }
642
+
643
+ /**
644
+ * Saves a routed record, converting model validation failures into
645
+ * client-safe per-sync errors carrying the translated validation message.
646
+ * @param {import("../database/record/index.js").default} record - Record to save.
647
+ * @returns {Promise<void>} Resolves when saved.
648
+ */
649
+ async saveRoutedReplayRecord(record) {
650
+ try {
651
+ await record.save()
652
+ } catch (error) {
653
+ throw this.routedReplaySaveError(error)
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Maps a routed save/create failure: model validation errors become
659
+ * client-safe errors with their translated messages, everything else
660
+ * propagates unchanged.
661
+ * @param {?} error - Thrown save/create error.
662
+ * @returns {Error} Error to rethrow.
663
+ */
664
+ routedReplaySaveError(error) {
665
+ if (error instanceof ValidationError) {
666
+ return VelociousError.safe(error.message, {cause: error, code: "validation-error"})
667
+ }
668
+
669
+ return /** @type {Error} */ (error)
338
670
  }
339
671
 
340
672
  /**
@@ -428,6 +760,14 @@ export default class SyncEnvelopeReplayService {
428
760
  }
429
761
  }
430
762
 
763
+ /**
764
+ * Resolved routed-resource registration for one replay resource type.
765
+ * @typedef {object} SyncReplayResourceRegistration
766
+ * @property {string} modelName - Effective frontend model name.
767
+ * @property {import("../configuration-types.js").FrontendModelResourceClassType} resourceClass - Routed resource class.
768
+ * @property {import("../configuration-types.js").NormalizedFrontendModelResourceConfiguration | null} resourceConfiguration - Normalized resource configuration when registry-resolved.
769
+ */
770
+
431
771
  /**
432
772
  * @typedef {object} SyncReplayMutation
433
773
  * @property {Date} clientUpdatedAt - Client-side mutation timestamp.
@@ -45,6 +45,7 @@ function optionalSyncDate(value, label) {
45
45
  * present-key filtering, per-field type coercion, unknown-key rejection, the
46
46
  * find-or-create upsert, the delete branch, optional snapshot serialization,
47
47
  * and the domain after-apply tail. Apps declare only the field map and hooks.
48
+ * @deprecated Prefer resource-routed replay (`SyncEnvelopeReplayService` with `configuration`/`resourceTypeOverrides` and resource `writableAttributes` permit lists) — value casting and validation belong to the record layer. This applier remains for released applyHandlers adopters and will be removed after their migration.
48
49
  */
49
50
  export default class SyncReplayUpsertApplier {
50
51
  /**
@@ -3,7 +3,7 @@
3
3
  import {forcedNonBlankStringParam} from "typanic"
4
4
 
5
5
  import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
6
- import normalizeAttributesWithSchema from "./sync-attribute-normalizer.js"
6
+ import SyncEnvelopeReplayService from "./sync-envelope-replay-service.js"
7
7
  import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
8
8
  import VelociousError from "../velocious-error.js"
9
9
 
@@ -26,6 +26,13 @@ const QUICK_SEARCH_COLUMN = "quickSearch"
26
26
  * @augments {FrontendModelBaseResource<TModelClass>}
27
27
  */
28
28
  export default class SyncResourceBase extends FrontendModelBaseResource {
29
+ /**
30
+ * Replay service class handling replay mutations for this resource,
31
+ * declared instead of overriding {@link SyncResourceBase#replayServiceClass}.
32
+ * @type {typeof import("./sync-envelope-replay-service.js").default | undefined}
33
+ */
34
+ static ReplayServiceClass = undefined
35
+
29
36
  /**
30
37
  * Declarative quick-search text columns. When declared, an index search on
31
38
  * the pseudo-column `quickSearch` expands to an OR of LIKE conditions over
@@ -33,13 +40,6 @@ export default class SyncResourceBase extends FrontendModelBaseResource {
33
40
  * @type {string[] | null} */
34
41
  static quickSearchColumns = null
35
42
 
36
- /**
37
- * Declarative writable-attribute schema consumed by
38
- * {@link SyncResourceBase#normalizeWritableAttributes}, keyed by camelCase
39
- * attribute name.
40
- * @type {Record<string, import("./sync-attribute-normalizer.js").SyncAttributeSchemaEntry> | null} */
41
- static writableAttributes = null
42
-
43
43
  /**
44
44
  * Applies frontend-model index searches, expanding declared quick searches.
45
45
  * @param {object} args - Search args.
@@ -91,39 +91,9 @@ export default class SyncResourceBase extends FrontendModelBaseResource {
91
91
  return true
92
92
  }
93
93
 
94
- /**
95
- * Normalizes incoming writable attributes through the declared
96
- * {@link SyncResourceBase.writableAttributes} schema: camelCase and
97
- * snake_case input keys are accepted, values are validated per type and the
98
- * normalized values are written under snake_case column keys. Validation
99
- * failures throw client-safe errors built by
100
- * {@link SyncResourceBase#writableAttributeError}.
101
- * @param {Record<string, ?>} attributes - Raw incoming attributes.
102
- * @param {{unknownAttributes?: "error" | "ignore"}} [options] - Unknown input-key handling. Defaults to "error".
103
- * @returns {Record<string, ?>} Normalized attributes keyed by column names.
104
- */
105
- normalizeWritableAttributes(attributes, options = {}) {
106
- const schema = /** @type {typeof SyncResourceBase} */ (this.constructor).writableAttributes
107
-
108
- if (!schema) throw new Error(`${this.constructor.name} must define static writableAttributes to use normalizeWritableAttributes`)
109
-
110
- return normalizeAttributesWithSchema({
111
- attributes,
112
- errorFactory: (message, details) => this.writableAttributeError(message, details),
113
- schema,
114
- unknownAttributes: options.unknownAttributes
115
- })
116
- }
117
-
118
- /**
119
- * Builds the client-safe error thrown for a failed writable-attribute validation.
120
- * @param {string} message - Human-readable validation message.
121
- * @param {{cause?: Error, code: string}} details - Stable machine-readable code and optional cause.
122
- * @returns {Error} Client-safe error.
123
- */
124
- writableAttributeError(message, {cause, code}) {
125
- return VelociousError.safe(message, cause ? {cause, code} : {code})
126
- }
94
+ // The declarative `static writableAttributes` permit list lives on
95
+ // FrontendModelBaseResource so every frontend-model resource can declare
96
+ // one; sync resources inherit it unchanged.
127
97
 
128
98
  /**
129
99
  * Returns a stable change-feed page after app authorization.
@@ -189,13 +159,22 @@ export default class SyncResourceBase extends FrontendModelBaseResource {
189
159
  }
190
160
 
191
161
  /**
192
- * Builds the app replay service handling this replay request.
162
+ * Builds the app replay service handling this replay request. The resource
163
+ * ability, context, configuration, and locals are plumbed in under the
164
+ * app-declared {@link SyncResourceBase#replayServiceArgs} (app args win) so
165
+ * the default resource-routed replay works without wiring.
193
166
  * @returns {import("./sync-envelope-replay-service.js").default} Replay service instance.
194
167
  */
195
168
  buildReplayService() {
196
169
  const ReplayServiceClass = this.replayServiceClass()
197
170
 
198
- return new ReplayServiceClass(this.replayServiceArgs())
171
+ return new ReplayServiceClass({
172
+ ability: this.ability,
173
+ abilityContext: this.getContext(),
174
+ configuration: this.controller ? this.controllerInstance().getConfiguration() : undefined,
175
+ locals: this.getLocals(),
176
+ ...this.replayServiceArgs()
177
+ })
199
178
  }
200
179
 
201
180
  /**
@@ -237,10 +216,18 @@ export default class SyncResourceBase extends FrontendModelBaseResource {
237
216
  }
238
217
 
239
218
  /**
240
- * Returns the app replay service class handling replay mutations.
219
+ * Resolves the replay service class handling replay mutations: the
220
+ * declarative {@link SyncResourceBase.ReplayServiceClass} static (shared
221
+ * resources included) when declared, otherwise
222
+ * {@link SyncEnvelopeReplayService}, which resource-routes mutations through
223
+ * the plumbed configuration registry. Apps declare the static instead of
224
+ * overriding this method.
241
225
  * @returns {typeof import("./sync-envelope-replay-service.js").default} Replay service class.
242
226
  */
243
227
  replayServiceClass() {
244
- throw new Error("SyncResourceBase#replayServiceClass must be implemented")
228
+ const ResourceClass = /** @type {typeof SyncResourceBase} */ (this.constructor)
229
+ const SharedResource = /** @type {typeof SyncResourceBase | null} */ (ResourceClass.sharedResourceClass() ?? null)
230
+
231
+ return ResourceClass.ReplayServiceClass ?? SharedResource?.ReplayServiceClass ?? SyncEnvelopeReplayService
245
232
  }
246
233
  }