velocious 1.0.495 → 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.
- package/build/database/record/index.js +2 -0
- package/build/database/record/validation-messages.js +50 -0
- package/build/database/record/validators/length.js +52 -0
- package/build/database/record/validators/presence.js +4 -1
- package/build/database/record/validators/uniqueness.js +4 -1
- package/build/frontend-model-resource/base-resource.js +175 -5
- package/build/frontend-models/resource-definition.js +46 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +3 -1
- package/build/src/database/record/validation-messages.d.ts +38 -0
- package/build/src/database/record/validation-messages.d.ts.map +1 -0
- package/build/src/database/record/validation-messages.js +45 -0
- package/build/src/database/record/validators/length.d.ts +12 -0
- package/build/src/database/record/validators/length.d.ts.map +1 -0
- package/build/src/database/record/validators/length.js +45 -0
- package/build/src/database/record/validators/presence.d.ts.map +1 -1
- package/build/src/database/record/validators/presence.js +4 -2
- package/build/src/database/record/validators/uniqueness.d.ts.map +1 -1
- package/build/src/database/record/validators/uniqueness.js +4 -2
- package/build/src/frontend-model-resource/base-resource.d.ts +184 -5
- package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
- package/build/src/frontend-model-resource/base-resource.js +155 -6
- package/build/src/frontend-models/resource-definition.d.ts +41 -0
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +42 -1
- package/build/src/sync/sync-envelope-replay-service.d.ts +166 -3
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +304 -12
- package/build/src/sync/sync-replay-upsert-applier.d.ts +1 -0
- package/build/src/sync/sync-replay-upsert-applier.d.ts.map +1 -1
- package/build/src/sync/sync-replay-upsert-applier.js +2 -1
- package/build/src/sync/sync-resource-base.d.ts +16 -32
- package/build/src/sync/sync-resource-base.d.ts.map +1 -1
- package/build/src/sync/sync-resource-base.js +31 -43
- package/build/src/testing/test-runner.d.ts +12 -9
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +30 -9
- package/build/sync/sync-envelope-replay-service.js +350 -10
- package/build/sync/sync-replay-upsert-applier.js +1 -0
- package/build/sync/sync-resource-base.js +32 -45
- package/build/testing/test-runner.js +30 -8
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/scripts/run-tests.js +6 -0
- package/src/database/record/index.js +2 -0
- package/src/database/record/validation-messages.js +50 -0
- package/src/database/record/validators/length.js +52 -0
- package/src/database/record/validators/presence.js +4 -1
- package/src/database/record/validators/uniqueness.js +4 -1
- package/src/frontend-model-resource/base-resource.js +175 -5
- package/src/frontend-models/resource-definition.js +46 -0
- package/src/sync/sync-envelope-replay-service.js +350 -10
- package/src/sync/sync-replay-upsert-applier.js +1 -0
- package/src/sync/sync-resource-base.js +32 -45
- package/src/testing/test-runner.js +30 -8
- package/build/src/sync/sync-attribute-normalizer.d.ts +0 -105
- package/build/src/sync/sync-attribute-normalizer.d.ts.map +0 -1
- package/build/src/sync/sync-attribute-normalizer.js +0 -172
- package/build/sync/sync-attribute-normalizer.js +0 -186
- 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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
*
|
|
326
|
-
* mutations without a
|
|
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 (
|
|
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
|
|
424
|
+
const resolvedRegistration = resolveFrontendModelResourceClass({configuration: this.configuration, resourceType: registryResourceType})
|
|
334
425
|
|
|
335
|
-
if (!
|
|
426
|
+
if (!resolvedRegistration) return null
|
|
336
427
|
|
|
337
|
-
return
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -643,15 +643,18 @@ export default class TestRunner {
|
|
|
643
643
|
/**
|
|
644
644
|
* Records an asynchronous crash (an unhandled promise rejection detached from
|
|
645
645
|
* any await, e.g. a `void connection.afterCommit(async () => broadcast(...))`
|
|
646
|
-
* frontend-model publish
|
|
646
|
+
* frontend-model publish — or a synchronous throw inside a detached callback
|
|
647
|
+
* such as a driver socket or timer callback) as a real, visible, attributed
|
|
648
|
+
* test failure.
|
|
647
649
|
*
|
|
648
|
-
* Without this, such a rejection has no handler, so on modern Node
|
|
649
|
-
* is TERMINATED — the run ends with no reported failures and CI
|
|
650
|
-
* crashed/retried shard with an empty result (the recurring
|
|
651
|
-
* test-runner death": invisible and impossible to diagnose). Turning
|
|
652
|
-
* failure makes the run go red with something debuggable instead of
|
|
653
|
-
*
|
|
654
|
-
* @param {
|
|
650
|
+
* Without this, such a rejection/exception has no handler, so on modern Node
|
|
651
|
+
* the process is TERMINATED — the run ends with no reported failures and CI
|
|
652
|
+
* just sees a crashed/retried shard with an empty result (the recurring
|
|
653
|
+
* "silent test-runner death": invisible and impossible to diagnose). Turning
|
|
654
|
+
* it into a failure makes the run go red with something debuggable instead of
|
|
655
|
+
* vanishing.
|
|
656
|
+
* @param {"uncaughtException" | "unhandledRejection"} kind - Async-crash kind.
|
|
657
|
+
* @param {unknown} reason - Rejection reason or thrown error.
|
|
655
658
|
* @returns {void}
|
|
656
659
|
*/
|
|
657
660
|
recordAsyncCrash(kind, reason) {
|
|
@@ -690,7 +693,25 @@ export default class TestRunner {
|
|
|
690
693
|
this.recordAsyncCrash("unhandledRejection", reason)
|
|
691
694
|
}
|
|
692
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Handles a process-level uncaught exception during the run — a
|
|
698
|
+
* synchronous throw inside a detached callback (driver socket, timer,
|
|
699
|
+
* event emitter) that no test await observes. Same silent-death mode as
|
|
700
|
+
* unhandled rejections: without a handler the process dies mid-run and CI
|
|
701
|
+
* sees a crashed shard with zero reported failures.
|
|
702
|
+
* @param {unknown} error - Thrown error.
|
|
703
|
+
* @returns {void}
|
|
704
|
+
*/
|
|
705
|
+
const onUncaughtException = (error) => {
|
|
706
|
+
// Mirror the unhandledRejection deferral: a test observing/triggering
|
|
707
|
+
// uncaught exceptions with its own listener owns them.
|
|
708
|
+
if (process.listenerCount("uncaughtException") > 1) return
|
|
709
|
+
|
|
710
|
+
this.recordAsyncCrash("uncaughtException", error)
|
|
711
|
+
}
|
|
712
|
+
|
|
693
713
|
process.on("unhandledRejection", onUnhandledRejection)
|
|
714
|
+
process.on("uncaughtException", onUncaughtException)
|
|
694
715
|
|
|
695
716
|
try {
|
|
696
717
|
await this.getConfiguration().ensureConnections({name: "Test runner suite"}, async () => {
|
|
@@ -713,6 +734,7 @@ export default class TestRunner {
|
|
|
713
734
|
})
|
|
714
735
|
} finally {
|
|
715
736
|
process.off("unhandledRejection", onUnhandledRejection)
|
|
737
|
+
process.off("uncaughtException", onUncaughtException)
|
|
716
738
|
}
|
|
717
739
|
}
|
|
718
740
|
|