velocious 1.0.502 → 1.0.503

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 (45) hide show
  1. package/build/application.js +2 -0
  2. package/build/src/application.d.ts.map +1 -1
  3. package/build/src/application.js +3 -1
  4. package/build/src/sync/sync-change-fanout.d.ts +71 -0
  5. package/build/src/sync/sync-change-fanout.d.ts.map +1 -0
  6. package/build/src/sync/sync-change-fanout.js +54 -0
  7. package/build/src/sync/sync-client-types.d.ts +4 -0
  8. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  9. package/build/src/sync/sync-client-types.js +1 -1
  10. package/build/src/sync/sync-client.d.ts.map +1 -1
  11. package/build/src/sync/sync-client.js +7 -3
  12. package/build/src/sync/sync-envelope-replay-service.d.ts +15 -4
  13. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  14. package/build/src/sync/sync-envelope-replay-service.js +60 -42
  15. package/build/src/sync/sync-publish-suppression.d.ts +31 -0
  16. package/build/src/sync/sync-publish-suppression.d.ts.map +1 -0
  17. package/build/src/sync/sync-publish-suppression.js +54 -0
  18. package/build/src/sync/sync-publisher-types.d.ts +146 -0
  19. package/build/src/sync/sync-publisher-types.d.ts.map +1 -0
  20. package/build/src/sync/sync-publisher-types.js +3 -0
  21. package/build/src/sync/sync-publisher.d.ts +137 -0
  22. package/build/src/sync/sync-publisher.d.ts.map +1 -0
  23. package/build/src/sync/sync-publisher.js +305 -0
  24. package/build/src/testing/test-runner.d.ts +8 -0
  25. package/build/src/testing/test-runner.d.ts.map +1 -1
  26. package/build/src/testing/test-runner.js +64 -2
  27. package/build/sync/sync-change-fanout.js +58 -0
  28. package/build/sync/sync-client-types.js +1 -0
  29. package/build/sync/sync-client.js +7 -2
  30. package/build/sync/sync-envelope-replay-service.js +60 -41
  31. package/build/sync/sync-publish-suppression.js +59 -0
  32. package/build/sync/sync-publisher-types.js +64 -0
  33. package/build/sync/sync-publisher.js +351 -0
  34. package/build/testing/test-runner.js +69 -1
  35. package/build/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +1 -1
  37. package/src/application.js +2 -0
  38. package/src/sync/sync-change-fanout.js +58 -0
  39. package/src/sync/sync-client-types.js +1 -0
  40. package/src/sync/sync-client.js +7 -2
  41. package/src/sync/sync-envelope-replay-service.js +60 -41
  42. package/src/sync/sync-publish-suppression.js +59 -0
  43. package/src/sync/sync-publisher-types.js +64 -0
  44. package/src/sync/sync-publisher.js +351 -0
  45. package/src/testing/test-runner.js +69 -1
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * One declarative broadcast delivered through an injected broadcaster. The
5
+ * replay service resolves it with replay args ({actor, applyResult, mutation,
6
+ * ...}) and the sync publisher with publish args
7
+ * ({@link import("./sync-publisher-types.js").SyncPublishBroadcastArgs}).
8
+ * @template [Args=Record<string, ?>]
9
+ * @typedef {object} DeclaredBroadcast
10
+ * @property {string | ((args: Args) => string)} channel - Channel name or resolver.
11
+ * @property {(args: Args) => Record<string, ?>} broadcastParams - Channel routing params.
12
+ * @property {(args: Args) => ?} body - Broadcast body.
13
+ * @property {(args: Args) => boolean} [when] - Optional gate; skipped when it returns false.
14
+ */
15
+
16
+ /**
17
+ * Upserts one sync/change row through the shared sync-model contract: an
18
+ * existing row for the resource identity is reassigned and re-sequenced
19
+ * through `advanceServerSequence()` (the change-feed sequence contract) so
20
+ * feed cursors pick the change up again; otherwise a new row is created
21
+ * (creates allocate their sequence through the model's own hooks). Shared by
22
+ * the replay service's model-backed persistence and the server sync
23
+ * publisher.
24
+ * @param {{attributes: Record<string, ?>, existingSync: ?, syncModel: ?}} args - Row attributes, existing row for the resource identity, and the sync model.
25
+ * @returns {Promise<?>} Upserted sync row.
26
+ */
27
+ export async function upsertSyncRow({attributes, existingSync, syncModel}) {
28
+ if (existingSync) {
29
+ existingSync.assign(attributes)
30
+ await existingSync.advanceServerSequence()
31
+ await existingSync.save()
32
+
33
+ return existingSync
34
+ }
35
+
36
+ return await syncModel.create(attributes)
37
+ }
38
+
39
+ /**
40
+ * Delivers declarative broadcasts through an injected broadcaster: each
41
+ * broadcast's `when` gate is checked, then channel/params/body are resolved
42
+ * from the caller's args. Shared by the replay service's default
43
+ * afterReplayMutation and the server sync publisher.
44
+ * @template Args
45
+ * @param {{args: Args, broadcaster: (broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>, broadcasts: Array<DeclaredBroadcast<Args>>}} deliveryArgs - Broadcast resolver args, broadcaster, and declared broadcasts.
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function deliverDeclaredBroadcasts({args, broadcaster, broadcasts}) {
49
+ for (const broadcast of broadcasts) {
50
+ if (broadcast.when && !broadcast.when(args)) continue
51
+
52
+ await broadcaster({
53
+ body: broadcast.body(args),
54
+ channel: typeof broadcast.channel === "function" ? broadcast.channel(args) : broadcast.channel,
55
+ params: broadcast.broadcastParams(args)
56
+ })
57
+ }
58
+ }
@@ -43,6 +43,7 @@
43
43
  * @property {import("./sync-api-client-types.js").SyncResourceConfig["findRecord"]} [findRecord] - Custom pull-apply record resolver.
44
44
  * @property {import("./sync-api-client-types.js").SyncResourceConfig["findRecordForDelete"]} [findRecordForDelete] - Custom pull-apply delete resolver.
45
45
  * @property {string[]} [localOnlyAttributes] - Extra local-only attributes merged with the derived primary key, createdAt/updatedAt, and sync bookkeeping attributes.
46
+ * @property {import("./sync-publisher-types.js").SyncPublishDeclaration} [publish] - Server-side publish declaration consumed by `SyncPublisher.fromConfiguration(...)` on the backend; ignored by the client.
46
47
  * @property {"upsert" | ((args: {operation: "create" | "update" | "destroy", record: ?}) => string)} [syncType] - Sync type flag or mapper (see SyncClientResourceConfig).
47
48
  * @property {boolean | Array<"create" | "update" | "destroy"> | {operations: Array<"create" | "update" | "destroy">}} [track] - Automatic mutation tracking policy; an array is shorthand for {operations}. On by default (creates and updates queue automatically); `false` opts the model out (for models written by non-user flows), `true` adds destroys.
48
49
  * @property {(args: {operation: "create" | "update" | "destroy", record: ?}) => Record<string, ?>} [trackedData] - Custom queued-payload builder for tracked mutations.
@@ -661,11 +661,16 @@ function resourceConfigFromSyncDeclaration({declaration, modelClass, resourceTyp
661
661
  throw new Error(`${resourceType} static sync must be true or a sync declaration object, got: ${String(declaration)}`)
662
662
  }
663
663
 
664
- const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
664
+ const {afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData, ...restDeclaration} = normalizedDeclaration
665
665
  const unknownKeys = Object.keys(restDeclaration)
666
666
 
667
+ // `publish` is the server-side half of the shared `static sync` declaration
668
+ // (consumed by SyncPublisher on the backend) - the client derives nothing
669
+ // from it, but models declared once for both sides must stay valid here.
670
+ void publish
671
+
667
672
  if (unknownKeys.length > 0) {
668
- throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, realtime, syncType, track, trackedData)`)
673
+ throw new Error(`${resourceType} static sync received unknown keys: ${unknownKeys.join(", ")} (supported: afterApply, attributes, booleanAttributes, findRecord, findRecordForDelete, localOnlyAttributes, publish, realtime, syncType, track, trackedData)`)
669
674
  }
670
675
  if (syncType !== undefined && typeof syncType !== "function" && syncType !== "upsert") {
671
676
  throw new Error(`${resourceType} static sync syncType must be a function or the string "upsert", got: ${String(syncType)}`)
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import {deliverDeclaredBroadcasts, upsertSyncRow} from "./sync-change-fanout.js"
4
+ import {markServerApply} from "./sync-publish-suppression.js"
3
5
  import {resolveFrontendModelResourceClass} from "../frontend-models/resource-definition.js"
4
6
  import SyncReplayUpsertApplier from "./sync-replay-upsert-applier.js"
5
7
  import {ValidationError} from "../database/record/index.js"
@@ -485,7 +487,11 @@ export default class SyncEnvelopeReplayService {
485
487
  }
486
488
 
487
489
  /**
488
- * Applies a routed delete mutation.
490
+ * Applies a routed delete mutation. The record is marked as a server apply
491
+ * for the duration of the replay-owned destroy - an active SyncPublisher
492
+ * never publishes the replayed delete a second time (the replay owns its
493
+ * own persist and broadcasts), while later server-side writes to the same
494
+ * instance publish normally again.
489
495
  * @param {object} args - Options.
490
496
  * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
491
497
  * @param {import("../frontend-model-resource/base-resource.js").default} args.resource - Routed resource instance.
@@ -496,7 +502,13 @@ export default class SyncEnvelopeReplayService {
496
502
 
497
503
  if (!record) return {created: false, deleted: false, record: null}
498
504
 
499
- await record.destroy()
505
+ const releaseServerApply = markServerApply(record)
506
+
507
+ try {
508
+ await record.destroy()
509
+ } finally {
510
+ releaseServerApply()
511
+ }
500
512
 
501
513
  return {created: false, deleted: true, record}
502
514
  }
@@ -506,8 +518,12 @@ export default class SyncEnvelopeReplayService {
506
518
  * assigned and saved onto the found record (the record layer owns value
507
519
  * casting and validation), and missing records are created with the
508
520
  * 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.
521
+ * Written records are marked as server applies for the duration of the
522
+ * replay-owned write - an active SyncPublisher never publishes the replayed
523
+ * mutation a second time (the replay owns its own persist and broadcasts),
524
+ * while later server-side writes to the same instance publish normally
525
+ * again. Model validation failures become client-safe per-sync failures
526
+ * carrying the translated validation message.
511
527
  * @param {object} args - Options.
512
528
  * @param {Record<string, ?>} args.context - Replay context.
513
529
  * @param {import("./sync-envelope-replay-service.js").SyncReplayMutation} args.mutation - Normalized replay mutation.
@@ -523,8 +539,14 @@ export default class SyncEnvelopeReplayService {
523
539
  let created = false
524
540
 
525
541
  if (existingRecord) {
526
- existingRecord.assign(attributes)
527
- await this.saveRoutedReplayRecord(existingRecord)
542
+ const releaseServerApply = markServerApply(existingRecord)
543
+
544
+ try {
545
+ existingRecord.assign(attributes)
546
+ await this.saveRoutedReplayRecord(existingRecord)
547
+ } finally {
548
+ releaseServerApply()
549
+ }
528
550
  } else {
529
551
  record = await this.createRoutedReplayRecord({attributes, mutation, resource})
530
552
  created = true
@@ -588,7 +610,10 @@ export default class SyncEnvelopeReplayService {
588
610
  }
589
611
 
590
612
  /**
591
- * Creates the routed record with the client-generated primary key, then
613
+ * Creates the routed record with the client-generated primary key (marked
614
+ * as a server apply for the duration of the create - including the
615
+ * membership-check compensation destroy - so an active SyncPublisher never
616
+ * publishes the replayed create a second time), then
592
617
  * verifies create-scope membership when an ability is configured: records
593
618
  * outside the ability's create scope are destroyed again and fail the sync
594
619
  * with the resource-declared reason. A record that already exists outside
@@ -611,33 +636,39 @@ export default class SyncEnvelopeReplayService {
611
636
  })
612
637
  }
613
638
 
614
- /** @type {import("../database/record/index.js").default} */
615
- let record
639
+ await ModelClass.ensureInitialized()
640
+
641
+ const record = new ModelClass({[primaryKey]: mutation.resourceId, ...attributes})
642
+ const releaseServerApply = markServerApply(record)
616
643
 
617
644
  try {
618
- record = await ModelClass.create({[primaryKey]: mutation.resourceId, ...attributes})
619
- } catch (error) {
620
- throw this.routedReplaySaveError(error)
621
- }
645
+ try {
646
+ await record.save()
647
+ } catch (error) {
648
+ throw this.routedReplaySaveError(error)
649
+ }
622
650
 
623
- const ability = resource.ability
651
+ const ability = resource.ability
624
652
 
625
- if (ability) {
626
- const memberIds = await ModelClass
627
- .accessibleFor(resource.syncAbilityAction("create"), ability)
628
- .where({[primaryKey]: record.id()})
629
- .pluck(primaryKey)
653
+ if (ability) {
654
+ const memberIds = await ModelClass
655
+ .accessibleFor(resource.syncAbilityAction("create"), ability)
656
+ .where({[primaryKey]: record.id()})
657
+ .pluck(primaryKey)
630
658
 
631
- if (memberIds.length === 0) {
632
- await record.destroy()
659
+ if (memberIds.length === 0) {
660
+ await record.destroy()
633
661
 
634
- throw VelociousError.safe(`Sync create denied for: ${mutation.resourceType}.`, {
635
- code: resource.syncAuthorizationFailureReason({action: "create", mutation}) || "access-denied"
636
- })
662
+ throw VelociousError.safe(`Sync create denied for: ${mutation.resourceType}.`, {
663
+ code: resource.syncAuthorizationFailureReason({action: "create", mutation}) || "access-denied"
664
+ })
665
+ }
637
666
  }
638
- }
639
667
 
640
- return record
668
+ return record
669
+ } finally {
670
+ releaseServerApply()
671
+ }
641
672
  }
642
673
 
643
674
  /**
@@ -709,13 +740,9 @@ export default class SyncEnvelopeReplayService {
709
740
  const existingClientUpdatedAt = this.existingReplaySyncClientUpdatedAt(existingSync)
710
741
 
711
742
  if (existingClientUpdatedAt && mutation.clientUpdatedAt <= existingClientUpdatedAt) return
712
-
713
- existingSync.assign(attributes)
714
- await existingSync.advanceServerSequence()
715
- await existingSync.save()
716
- } else {
717
- await this.syncModel.create(attributes)
718
743
  }
744
+
745
+ await upsertSyncRow({attributes, existingSync, syncModel: this.syncModel})
719
746
  }
720
747
 
721
748
  /**
@@ -748,15 +775,7 @@ export default class SyncEnvelopeReplayService {
748
775
  // would fan out stale side effects (or crash on the default null applyResult).
749
776
  if (!args.shouldApply) return
750
777
 
751
- for (const broadcast of this.broadcasts) {
752
- if (broadcast.when && !broadcast.when(args)) continue
753
-
754
- await this.broadcaster({
755
- body: broadcast.body(args),
756
- channel: typeof broadcast.channel === "function" ? broadcast.channel(args) : broadcast.channel,
757
- params: broadcast.broadcastParams(args)
758
- })
759
- }
778
+ await deliverDeclaredBroadcasts({args, broadcaster: this.broadcaster, broadcasts: this.broadcasts})
760
779
  }
761
780
  }
762
781
 
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Records currently being written from an already-synced source (sync replay
5
+ * applies, importers applying device-origin data). Module-scoped so the
6
+ * framework's replay apply can mark records without holding a publisher
7
+ * instance.
8
+ * @type {WeakSet<object>}
9
+ */
10
+ const serverApplyRecords = new WeakSet()
11
+
12
+ let withoutPublishingDepth = 0
13
+
14
+ /**
15
+ * Marks one record as being written from an already-synced source so server
16
+ * publish callbacks skip it (record-precise suppression). The framework's
17
+ * routed sync replay apply uses this internally around every applied write, so
18
+ * replayed device mutations never publish a second, server-origin sync change.
19
+ * @param {?} record - Server model record about to be written.
20
+ * @returns {() => void} Release callback re-enabling publishing for the record.
21
+ */
22
+ export function markServerApply(record) {
23
+ serverApplyRecords.add(record)
24
+
25
+ return () => serverApplyRecords.delete(record)
26
+ }
27
+
28
+ /**
29
+ * Runs a callback with server publish callbacks suppressed - for code applying
30
+ * already-synced data outside the framework's replay apply (importers,
31
+ * backfills applying device-origin rows). Suppression covers the whole async
32
+ * duration of the callback (nested calls stack) and is process-wide while it
33
+ * runs: mutations from concurrently running requests are also suppressed for
34
+ * that window, so prefer `markServerApply(record)` when other flows can
35
+ * interleave.
36
+ * @template T
37
+ * @param {() => Promise<T> | T} callback - Work whose model writes should not publish sync changes.
38
+ * @returns {Promise<T>} The callback result.
39
+ */
40
+ export async function withoutPublishing(callback) {
41
+ withoutPublishingDepth++
42
+
43
+ try {
44
+ return await callback()
45
+ } finally {
46
+ withoutPublishingDepth--
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether server publishing is currently suppressed for a record: either the
52
+ * record was marked as a server apply (`markServerApply`) or a
53
+ * `withoutPublishing` callback is running.
54
+ * @param {?} record - Server model record being written.
55
+ * @returns {boolean} Whether publishing is suppressed for the record.
56
+ */
57
+ export function isPublishingSuppressed(record) {
58
+ return withoutPublishingDepth > 0 || serverApplyRecords.has(record)
59
+ }
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Args resolved against a publish declaration's broadcasts after a published
5
+ * server-side mutation commits.
6
+ * @typedef {object} SyncPublishBroadcastArgs
7
+ * @property {Record<string, ?>} data - Payload snapshotted through the declaration's `serialize(record)` at mutation time.
8
+ * @property {"create" | "update" | "destroy"} operation - Mutation operation that published.
9
+ * @property {?} record - Mutated server model record.
10
+ * @property {string} resourceId - Published resource id.
11
+ * @property {string} resourceType - Published resource type.
12
+ * @property {?} syncRow - Upserted sync/change row.
13
+ * @property {string} syncType - Published sync type ("update" for creates/updates, "delete" for destroys).
14
+ */
15
+
16
+ /**
17
+ * One declarative broadcast fanned out after a published server-side mutation
18
+ * commits — same shape the replay service's injected broadcaster consumes.
19
+ * @typedef {object} SyncPublishBroadcast
20
+ * @property {string | ((args: SyncPublishBroadcastArgs) => string)} channel - Channel name or resolver.
21
+ * @property {(args: SyncPublishBroadcastArgs) => Record<string, ?>} broadcastParams - Channel routing params.
22
+ * @property {(args: SyncPublishBroadcastArgs) => ?} body - Broadcast body.
23
+ * @property {(args: SyncPublishBroadcastArgs) => boolean} [when] - Optional gate; skipped when it returns false.
24
+ */
25
+
26
+ /**
27
+ * Server-side publish declaration on a model's `static sync`, consumed by
28
+ * `SyncPublisher.fromConfiguration(...)`. Publishing is on for models
29
+ * declaring it (server-side creates and updates write to the sync change
30
+ * feed and broadcast automatically once their transaction commits);
31
+ * `publish: false` opts a model out explicitly.
32
+ * @typedef {object} SyncPublishDeclarationConfig
33
+ * @property {(record: ?) => Record<string, ?> | Promise<Record<string, ?>>} serialize - Builds the published payload snapshot from the mutated record (snapshotted at mutation time).
34
+ * @property {(record: ?) => string | number | null | Promise<string | number | null>} [eventId] - Resolves the event scope persisted to the sync row's event_id column.
35
+ * @property {SyncPublishBroadcast[]} [broadcasts] - Declarative broadcasts fanned out after the sync row is upserted.
36
+ * @property {Array<"create" | "update" | "destroy">} [operations] - Published operations. Defaults to creates and updates; destroys are opt-in because a server destroy is often cleanup rather than a synced delete.
37
+ * @property {string} [resourceType] - Published resource type. Defaults to the model name.
38
+ */
39
+
40
+ /** @typedef {false | SyncPublishDeclarationConfig} SyncPublishDeclaration */
41
+
42
+ /**
43
+ * Options for building a sync publisher. Published resources are derived from
44
+ * the configuration's registered models (`static sync` publish declarations).
45
+ * @typedef {object} SyncPublisherOptions
46
+ * @property {string} [actorForeignKeyColumn] - Sync model column linking rows to a device actor. Published server-origin rows set it to null (no device to echo). Defaults to "authentication_token_id".
47
+ * @property {(broadcast: {channel: string, params: Record<string, ?>, body: ?}) => Promise<void>} [broadcaster] - Delivers declared broadcasts. Defaults to the configuration's channel broadcast.
48
+ * @property {import("../configuration.js").default} [configuration] - Configuration owning the registered models. Defaults to the current configuration.
49
+ * @property {(error: Error) => void} [onError] - Reports post-commit publish failures. Defaults to loud logging.
50
+ * @property {?} [syncModel] - Sync/change model override. Defaults to the registered "Sync" model.
51
+ */
52
+
53
+ /**
54
+ * Internal derived publish policy for one resource — not an app-facing API.
55
+ * @typedef {object} SyncPublisherResourceConfig
56
+ * @property {SyncPublishBroadcast[] | undefined} broadcasts - Declared broadcasts.
57
+ * @property {SyncPublishDeclarationConfig["eventId"] | undefined} eventId - Event scope resolver.
58
+ * @property {?} modelClass - Server model class for this resource.
59
+ * @property {Array<"create" | "update" | "destroy">} operations - Published operations.
60
+ * @property {string} resourceType - Published resource type.
61
+ * @property {SyncPublishDeclarationConfig["serialize"]} serialize - Payload snapshot builder.
62
+ */
63
+
64
+ export {}