@baukit/sync-client 0.7.2 → 0.7.4

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/src/hlc.ts ADDED
@@ -0,0 +1,244 @@
1
+ /** Number of logical counter values available in one physical millisecond. */
2
+ export const HLC_COUNTERS_PER_MILLISECOND = 1_000;
3
+
4
+ /** Largest encoded timestamp JavaScript can represent exactly. */
5
+ export const MAX_HLC_TIMESTAMP = Number.MAX_SAFE_INTEGER;
6
+
7
+ /** Key passed to an injected HLC store. */
8
+ export const HLC_STORAGE_KEY = 'hlc-state';
9
+
10
+ /** Serializable state needed to restore a hybrid logical clock. */
11
+ export type HybridLogicalClockState = Readonly<
12
+ {
13
+ wallTimeMs: number;
14
+ counter: number;
15
+ deviceId: string;
16
+ } & Record<string, string | number>
17
+ >;
18
+
19
+ /** Async persistence supplied by a product storage adapter. */
20
+ export interface HlcStorage {
21
+ get(key: string): Promise<unknown>;
22
+ set(key: string, value: HybridLogicalClockState): Promise<void>;
23
+ }
24
+
25
+ /** Physical clock supplied by the host, usually `Date.now`. */
26
+ export type HlcPhysicalClock = () => number;
27
+
28
+ /** Stable validation and exhaustion error codes. */
29
+ export type HybridLogicalClockErrorCode =
30
+ | 'invalid_component'
31
+ | 'invalid_timestamp'
32
+ | 'exceeds_safe_integer'
33
+ | 'invalid_physical_clock'
34
+ | 'empty_device_id';
35
+
36
+ /** Invalid HLC input or exhausted JavaScript-safe timestamp space. */
37
+ export class HybridLogicalClockError extends Error {
38
+ public constructor(
39
+ readonly code: HybridLogicalClockErrorCode,
40
+ message: string,
41
+ ) {
42
+ super(message);
43
+ this.name = 'HybridLogicalClockError';
44
+ }
45
+ }
46
+
47
+ /** Encodes physical milliseconds and a logical counter into one timestamp. */
48
+ export function encodeHybridLogicalTimestamp(wallTimeMs: number, counter: number): number {
49
+ if (
50
+ !Number.isSafeInteger(wallTimeMs) ||
51
+ wallTimeMs < 0 ||
52
+ !Number.isInteger(counter) ||
53
+ counter < 0 ||
54
+ counter >= HLC_COUNTERS_PER_MILLISECOND
55
+ ) {
56
+ throw new HybridLogicalClockError(
57
+ 'invalid_component',
58
+ 'Invalid hybrid logical timestamp component',
59
+ );
60
+ }
61
+
62
+ const encoded = wallTimeMs * HLC_COUNTERS_PER_MILLISECOND + counter + 1;
63
+ if (!Number.isSafeInteger(encoded) || encoded < 1) {
64
+ throw new HybridLogicalClockError(
65
+ 'exceeds_safe_integer',
66
+ 'Hybrid logical timestamp exceeds safe integer range',
67
+ );
68
+ }
69
+ return encoded;
70
+ }
71
+
72
+ /** Decodes a positive JavaScript-safe timestamp into physical and logical parts. */
73
+ export function decodeHybridLogicalTimestamp(timestamp: number): Readonly<{
74
+ wallTimeMs: number;
75
+ counter: number;
76
+ }> {
77
+ if (!isEncodedTimestamp(timestamp)) {
78
+ throw new HybridLogicalClockError(
79
+ 'invalid_timestamp',
80
+ 'Invalid encoded hybrid logical timestamp',
81
+ );
82
+ }
83
+
84
+ const zeroBased = timestamp - 1;
85
+ return Object.freeze({
86
+ wallTimeMs: Math.floor(zeroBased / HLC_COUNTERS_PER_MILLISECOND),
87
+ counter: zeroBased % HLC_COUNTERS_PER_MILLISECOND,
88
+ });
89
+ }
90
+
91
+ /** Compares two valid encoded timestamps, or returns `null` for invalid input. */
92
+ export function compareHybridLogicalTimestamps(left: number, right: number): -1 | 0 | 1 | null {
93
+ if (!isEncodedTimestamp(left) || !isEncodedTimestamp(right)) return null;
94
+ if (left < right) return -1;
95
+ if (left > right) return 1;
96
+ return 0;
97
+ }
98
+
99
+ /** Hybrid logical clock with injected time and optional persistence. */
100
+ export class HybridLogicalClock {
101
+ readonly #deviceId: string;
102
+ readonly #physicalClock: HlcPhysicalClock;
103
+ readonly #storage: HlcStorage | undefined;
104
+ #state: HybridLogicalClockState;
105
+ #queue: Promise<void> = Promise.resolve();
106
+
107
+ private constructor(
108
+ deviceId: string,
109
+ physicalClock: HlcPhysicalClock,
110
+ storage: HlcStorage | undefined,
111
+ state: HybridLogicalClockState,
112
+ ) {
113
+ this.#deviceId = deviceId;
114
+ this.#physicalClock = physicalClock;
115
+ this.#storage = storage;
116
+ this.#state = state;
117
+ }
118
+
119
+ /** Opens a clock from optional injected storage. Invalid stored state resets to zero. */
120
+ public static async open(
121
+ deviceId: string,
122
+ storage?: HlcStorage,
123
+ physicalClock: HlcPhysicalClock = Date.now,
124
+ ): Promise<HybridLogicalClock> {
125
+ validateDeviceId(deviceId);
126
+ const persisted = storage ? parseState(await storage.get(HLC_STORAGE_KEY), deviceId) : null;
127
+ const state = persisted ?? freezeState({ wallTimeMs: 0, counter: 0, deviceId });
128
+ return new HybridLogicalClock(deviceId, physicalClock, storage, state);
129
+ }
130
+
131
+ /** Returns the next local timestamp after committing its state. */
132
+ public now(): Promise<number> {
133
+ return this.#serialized(async () => {
134
+ const physical = readPhysicalTime(this.#physicalClock);
135
+ const wallTimeMs = Math.max(physical, this.#state.wallTimeMs);
136
+ const counter = physical > this.#state.wallTimeMs ? 0 : this.#state.counter + 1;
137
+ return this.#advance(wallTimeMs, counter);
138
+ });
139
+ }
140
+
141
+ /** Observes a remote timestamp and returns a committed timestamp ordered after it. */
142
+ public observe(remoteTimestamp: number): Promise<number> {
143
+ return this.#serialized(async () => {
144
+ const remote = decodeHybridLogicalTimestamp(remoteTimestamp);
145
+ const physical = readPhysicalTime(this.#physicalClock);
146
+ const wallTimeMs = Math.max(physical, this.#state.wallTimeMs, remote.wallTimeMs);
147
+ let counter: number;
148
+ if (wallTimeMs === this.#state.wallTimeMs && wallTimeMs === remote.wallTimeMs) {
149
+ counter = Math.max(this.#state.counter, remote.counter) + 1;
150
+ } else if (wallTimeMs === this.#state.wallTimeMs) {
151
+ counter = this.#state.counter + 1;
152
+ } else if (wallTimeMs === remote.wallTimeMs) {
153
+ counter = remote.counter + 1;
154
+ } else {
155
+ counter = 0;
156
+ }
157
+ return this.#advance(wallTimeMs, counter);
158
+ });
159
+ }
160
+
161
+ /** Returns an immutable copy of the last committed state. */
162
+ public snapshot(): HybridLogicalClockState {
163
+ return freezeState(this.#state);
164
+ }
165
+
166
+ async #advance(wallTimeMs: number, counter: number): Promise<number> {
167
+ if (counter >= HLC_COUNTERS_PER_MILLISECOND) {
168
+ wallTimeMs += 1;
169
+ counter = 0;
170
+ }
171
+
172
+ const timestamp = encodeHybridLogicalTimestamp(wallTimeMs, counter);
173
+ const nextState = freezeState({ wallTimeMs, counter, deviceId: this.#deviceId });
174
+ if (this.#storage) await this.#storage.set(HLC_STORAGE_KEY, nextState);
175
+ this.#state = nextState;
176
+ return timestamp;
177
+ }
178
+
179
+ #serialized<TResult>(operation: () => Promise<TResult>): Promise<TResult> {
180
+ if (!this.#storage) return operation();
181
+
182
+ const result = this.#queue.then(operation, operation);
183
+ this.#queue = result.then(
184
+ () => undefined,
185
+ () => undefined,
186
+ );
187
+ return result;
188
+ }
189
+ }
190
+
191
+ function isEncodedTimestamp(timestamp: number): boolean {
192
+ return Number.isSafeInteger(timestamp) && timestamp >= 1;
193
+ }
194
+
195
+ function parseState(value: unknown, deviceId: string): HybridLogicalClockState | null {
196
+ if (typeof value !== 'object' || value === null) return null;
197
+ if (!('wallTimeMs' in value) || !('counter' in value) || !('deviceId' in value)) return null;
198
+
199
+ const candidate = value as {
200
+ readonly wallTimeMs: unknown;
201
+ readonly counter: unknown;
202
+ readonly deviceId: unknown;
203
+ };
204
+ if (
205
+ typeof candidate.wallTimeMs !== 'number' ||
206
+ typeof candidate.counter !== 'number' ||
207
+ candidate.deviceId !== deviceId
208
+ ) {
209
+ return null;
210
+ }
211
+
212
+ try {
213
+ encodeHybridLogicalTimestamp(candidate.wallTimeMs, candidate.counter);
214
+ } catch (error) {
215
+ if (error instanceof HybridLogicalClockError) return null;
216
+ throw error;
217
+ }
218
+ return freezeState({
219
+ wallTimeMs: candidate.wallTimeMs,
220
+ counter: candidate.counter,
221
+ deviceId,
222
+ });
223
+ }
224
+
225
+ function readPhysicalTime(clock: HlcPhysicalClock): number {
226
+ const value = Math.floor(clock());
227
+ if (!Number.isSafeInteger(value) || value < 0) {
228
+ throw new HybridLogicalClockError(
229
+ 'invalid_physical_clock',
230
+ 'Physical clock must return a non-negative safe integer',
231
+ );
232
+ }
233
+ return value;
234
+ }
235
+
236
+ function validateDeviceId(deviceId: string): void {
237
+ if (deviceId.trim().length === 0) {
238
+ throw new HybridLogicalClockError('empty_device_id', 'HLC device id must not be empty');
239
+ }
240
+ }
241
+
242
+ function freezeState(state: HybridLogicalClockState): HybridLogicalClockState {
243
+ return Object.freeze({ ...state });
244
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ export {
2
+ SyncAuthError,
3
+ SyncLocalApplyError,
4
+ SyncNetworkError,
5
+ SyncPartitionMismatchError,
6
+ SyncPayloadCompatibilityError,
7
+ SyncRateLimitError,
8
+ SyncServerError,
9
+ SyncTransportError,
10
+ syncFailureFromError,
11
+ } from './error.js';
12
+ export type { SyncFailure } from './error.js';
13
+ export {
14
+ compareHybridLogicalTimestamps,
15
+ decodeHybridLogicalTimestamp,
16
+ encodeHybridLogicalTimestamp,
17
+ HybridLogicalClock,
18
+ HybridLogicalClockError,
19
+ HLC_COUNTERS_PER_MILLISECOND,
20
+ HLC_STORAGE_KEY,
21
+ MAX_HLC_TIMESTAMP,
22
+ } from './hlc.js';
23
+ export type {
24
+ HlcPhysicalClock,
25
+ HlcStorage,
26
+ HybridLogicalClockErrorCode,
27
+ HybridLogicalClockState,
28
+ } from './hlc.js';
29
+ export { dependencyRankByOrder, rankPushBatch, validatePushOutcomeCoverage } from './push-batch.js';
30
+ export type {
31
+ PushCandidate,
32
+ PushOutcomeCoverageOptions,
33
+ RankedPushItem,
34
+ RankPushBatchOptions,
35
+ } from './push-batch.js';
36
+ export { SyncScheduler } from './scheduler.js';
37
+ export type {
38
+ SyncSchedulerEnvironment,
39
+ SyncSchedulerOptions,
40
+ SyncSchedulerRecoverySignal,
41
+ SyncSchedulerRetry,
42
+ SyncSchedulerRetryOptions,
43
+ SyncSchedulerTimer,
44
+ } from './scheduler.js';
45
+ export { deriveInitialSyncState, deriveLocalStoreReadiness, SyncStatusStore } from './store.js';
46
+ export type {
47
+ InitialPullStatus,
48
+ InitialSyncState,
49
+ LocalStoreReadiness,
50
+ LocalStoreReadinessInput,
51
+ SyncAttentionItem,
52
+ SyncFailureUpdate,
53
+ SyncStatus,
54
+ SyncStatusHydration,
55
+ SyncStatusSink,
56
+ SyncStatusSnapshot,
57
+ SyncStatusStoreOptions,
58
+ } from './store.js';
59
+ export {
60
+ commitCursorAfterLocalTransaction,
61
+ DEFAULT_RETRY_AFTER_FALLBACK_MS,
62
+ parseRetryAfter,
63
+ SyncTransport,
64
+ validatePullPage,
65
+ } from './transport.js';
66
+ export type {
67
+ CursorCommitOptions,
68
+ ParseRetryAfterOptions,
69
+ PullPagePosition,
70
+ SyncCursorComparator,
71
+ SyncFetch,
72
+ SyncFetchResponse,
73
+ SyncResponseHeaders,
74
+ SyncPrebuiltRequest,
75
+ SyncPrebuiltRequestTransportOptions,
76
+ SyncRequestOptions,
77
+ SyncRequestInit,
78
+ SyncFetchTransportOptions,
79
+ SyncTransportOptions,
80
+ } from './transport.js';
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { SyncPayloadCompatibilityError } from './error.js';
4
+ import {
5
+ dependencyRankByOrder,
6
+ rankPushBatch,
7
+ validatePushOutcomeCoverage,
8
+ type PushCandidate,
9
+ } from './push-batch.js';
10
+
11
+ // A three-level graph: a container owns groups, a group owns leaves.
12
+ const rank = dependencyRankByOrder(['container', 'group', 'leaf']);
13
+
14
+ function change(
15
+ entityType: string,
16
+ entityId: string,
17
+ changeId = `${entityType}-${entityId}`,
18
+ ): PushCandidate {
19
+ return { changeId, entityType, entityId };
20
+ }
21
+
22
+ function entityIds(items: readonly { change: PushCandidate }[]): string[] {
23
+ return items.map(({ change: candidate }) => `${candidate.entityType}:${candidate.entityId}`);
24
+ }
25
+
26
+ describe('dependencyRankByOrder', () => {
27
+ it('ranks listed types by position and unknown types last', () => {
28
+ expect(rank('container')).toBe(0);
29
+ expect(rank('leaf')).toBe(2);
30
+ expect(rank('unlisted')).toBe(3);
31
+ });
32
+ });
33
+
34
+ describe('rankPushBatch', () => {
35
+ it('orders a batch parent before child', () => {
36
+ const pending = [change('leaf', 'l1'), change('container', 'c1'), change('group', 'g1')];
37
+
38
+ const batch = rankPushBatch(pending, { rank, batchSize: 10 });
39
+
40
+ expect(entityIds(batch)).toEqual(['container:c1', 'group:g1', 'leaf:l1']);
41
+ });
42
+
43
+ it('keeps queue order within one entity type', () => {
44
+ const pending = [change('leaf', 'l2'), change('leaf', 'l1'), change('leaf', 'l3')];
45
+
46
+ const batch = rankPushBatch(pending, { rank, batchSize: 10 });
47
+
48
+ expect(entityIds(batch)).toEqual(['leaf:l2', 'leaf:l1', 'leaf:l3']);
49
+ });
50
+
51
+ it('sorts unknown entity types after every listed type', () => {
52
+ const pending = [change('unlisted', 'u1'), change('leaf', 'l1')];
53
+
54
+ const batch = rankPushBatch(pending, { rank, batchSize: 10 });
55
+
56
+ expect(entityIds(batch)).toEqual(['leaf:l1', 'unlisted:u1']);
57
+ });
58
+
59
+ it('coalesces repeated changes to one entity, sending the newest', () => {
60
+ const pending = [
61
+ change('leaf', 'l1', 'change-1'),
62
+ change('leaf', 'l1', 'change-2'),
63
+ change('leaf', 'l1', 'change-3'),
64
+ ];
65
+
66
+ const batch = rankPushBatch(pending, { rank, batchSize: 10 });
67
+
68
+ expect(batch).toHaveLength(1);
69
+ expect(batch[0]?.change.changeId).toBe('change-3');
70
+ expect(batch[0]?.coveredChangeIds).toEqual(['change-1', 'change-2', 'change-3']);
71
+ });
72
+
73
+ it('keeps the first occurrence position when coalescing, so parents stay ahead', () => {
74
+ const pending = [
75
+ change('container', 'c1', 'change-1'),
76
+ change('leaf', 'l1', 'change-2'),
77
+ change('container', 'c1', 'change-3'),
78
+ ];
79
+
80
+ const batch = rankPushBatch(pending, { rank, batchSize: 10 });
81
+
82
+ expect(entityIds(batch)).toEqual(['container:c1', 'leaf:l1']);
83
+ expect(batch[0]?.change.changeId).toBe('change-3');
84
+ });
85
+
86
+ it('truncates to the batch size after ordering, keeping parents', () => {
87
+ const pending = [
88
+ change('leaf', 'l1'),
89
+ change('leaf', 'l2'),
90
+ change('container', 'c1'),
91
+ change('group', 'g1'),
92
+ ];
93
+
94
+ const batch = rankPushBatch(pending, { rank, batchSize: 2 });
95
+
96
+ expect(entityIds(batch)).toEqual(['container:c1', 'group:g1']);
97
+ });
98
+
99
+ it('holds back a parent whose children are unsent, and sends the children', () => {
100
+ const pending = [
101
+ change('container', 'c1'),
102
+ change('group', 'g1'),
103
+ change('leaf', 'l1'),
104
+ change('leaf', 'l2'),
105
+ ];
106
+ const unsentChildren = new Set(['c1']);
107
+
108
+ const batch = rankPushBatch(pending, {
109
+ rank,
110
+ batchSize: 10,
111
+ isHeldBack: (candidate) =>
112
+ candidate.entityType === 'container' && unsentChildren.has(candidate.entityId),
113
+ });
114
+
115
+ expect(entityIds(batch)).toEqual(['group:g1', 'leaf:l1', 'leaf:l2']);
116
+ });
117
+
118
+ it('sends a held-back parent on the next run once its children settled', () => {
119
+ const pending = [change('container', 'c1'), change('leaf', 'l1')];
120
+ const held = new Set(['c1']);
121
+ const isHeldBack = (candidate: PushCandidate): boolean => held.has(candidate.entityId);
122
+
123
+ const first = rankPushBatch(pending, { rank, batchSize: 10, isHeldBack });
124
+ expect(entityIds(first)).toEqual(['leaf:l1']);
125
+
126
+ held.clear();
127
+ const second = rankPushBatch(pending, { rank, batchSize: 10, isHeldBack });
128
+ expect(entityIds(second)).toEqual(['container:c1', 'leaf:l1']);
129
+ });
130
+
131
+ it('holds back every coalesced change for the held entity', () => {
132
+ const pending = [change('container', 'c1', 'change-1'), change('container', 'c1', 'change-2')];
133
+
134
+ const batch = rankPushBatch(pending, {
135
+ rank,
136
+ batchSize: 10,
137
+ isHeldBack: (candidate) => candidate.entityId === 'c1',
138
+ });
139
+
140
+ expect(batch).toEqual([]);
141
+ });
142
+
143
+ it('counts held-back entities against neither the batch size nor the order', () => {
144
+ const pending = [change('container', 'c1'), change('group', 'g1'), change('leaf', 'l1')];
145
+
146
+ const batch = rankPushBatch(pending, {
147
+ rank,
148
+ batchSize: 2,
149
+ isHeldBack: (candidate) => candidate.entityType === 'container',
150
+ });
151
+
152
+ expect(entityIds(batch)).toEqual(['group:g1', 'leaf:l1']);
153
+ });
154
+
155
+ it('returns an empty batch for an empty queue', () => {
156
+ expect(rankPushBatch([], { rank, batchSize: 10 })).toEqual([]);
157
+ });
158
+
159
+ it('carries product-defined change fields through the batch', () => {
160
+ interface ProductChange extends PushCandidate {
161
+ payload: string;
162
+ }
163
+ const pending: ProductChange[] = [
164
+ { changeId: 'change-1', entityType: 'leaf', entityId: 'l1', payload: 'first' },
165
+ { changeId: 'change-2', entityType: 'leaf', entityId: 'l1', payload: 'second' },
166
+ ];
167
+
168
+ const batch = rankPushBatch(pending, { rank, batchSize: 10 });
169
+
170
+ expect(batch[0]?.change.payload).toBe('second');
171
+ });
172
+ });
173
+
174
+ describe('validatePushOutcomeCoverage', () => {
175
+ const key = ({ entityType, entityId }: { entityType: string; entityId: string }): string =>
176
+ `${entityType}:${entityId}`;
177
+
178
+ it('returns a complete mix of accepted and rejected outcomes', () => {
179
+ const submitted = [change('container', 'c1'), change('leaf', 'l1')];
180
+ const outcomes = [
181
+ { entityType: 'container', entityId: 'c1', result: 'accepted' },
182
+ { entityType: 'leaf', entityId: 'l1', result: 'rejected' },
183
+ ];
184
+
185
+ expect(
186
+ validatePushOutcomeCoverage(submitted, outcomes, {
187
+ submittedKey: key,
188
+ outcomeKey: key,
189
+ }),
190
+ ).toBe(outcomes);
191
+ });
192
+
193
+ it('rejects a partial outcome set before a caller acknowledges changes', () => {
194
+ const acknowledged: string[] = [];
195
+ const submitted = [change('container', 'c1'), change('leaf', 'l1')];
196
+ const outcomes = [{ entityType: 'container', entityId: 'c1', result: 'accepted' }];
197
+
198
+ expect(() => {
199
+ const validated = validatePushOutcomeCoverage(submitted, outcomes, {
200
+ submittedKey: key,
201
+ outcomeKey: key,
202
+ });
203
+ acknowledged.push(...validated.map(key));
204
+ }).toThrow(SyncPayloadCompatibilityError);
205
+ expect(acknowledged).toEqual([]);
206
+ });
207
+ });
@@ -0,0 +1,115 @@
1
+ import { SyncPayloadCompatibilityError } from './error.js';
2
+
3
+ /**
4
+ * One unsent local change, as baukit sees it.
5
+ *
6
+ * `entityType` and `entityId` identify the row the change targets. Baukit never
7
+ * interprets either: entity names, payloads, and operations stay product-owned.
8
+ */
9
+ export interface PushCandidate {
10
+ /** Stable identity of this queued change, unique across the outbox. */
11
+ changeId: string;
12
+ entityType: string;
13
+ entityId: string;
14
+ }
15
+
16
+ /** One entity's place in the batch, after coalescing and ordering. */
17
+ export interface RankedPushItem<T extends PushCandidate> {
18
+ /** The newest queued change for this entity; its payload is the one to send. */
19
+ change: T;
20
+ /**
21
+ * Every queued change this item settles, oldest first. One server outcome
22
+ * clears all of them, because outcomes are keyed by entity, not by change.
23
+ */
24
+ coveredChangeIds: string[];
25
+ }
26
+
27
+ export interface RankPushBatchOptions<T extends PushCandidate> {
28
+ /**
29
+ * Dependency rank of an entity type. Lower ranks are sent first, so a parent
30
+ * type must rank below every type that references it. Unknown types sort last.
31
+ */
32
+ rank: (entityType: string) => number;
33
+ /** Largest number of entities one request may carry. */
34
+ batchSize: number;
35
+ /**
36
+ * Entities whose required children are still unsent, or otherwise not yet
37
+ * safe to send. Held-back entities are dropped from the batch and retried on
38
+ * a later run, once their children have been accepted.
39
+ */
40
+ isHeldBack?: (change: T) => boolean;
41
+ }
42
+
43
+ export interface PushOutcomeCoverageOptions<TSubmitted, TOutcome> {
44
+ submittedKey: (submitted: TSubmitted) => string;
45
+ outcomeKey: (outcome: TOutcome) => string;
46
+ }
47
+
48
+ /**
49
+ * Rejects a response that omits an outcome for any submitted entity.
50
+ *
51
+ * Call this before acknowledging accepted or rejected changes. Products supply
52
+ * key readers because their wire field names remain product-owned.
53
+ */
54
+ export function validatePushOutcomeCoverage<TSubmitted, TOutcome>(
55
+ submitted: readonly TSubmitted[],
56
+ outcomes: readonly TOutcome[],
57
+ { submittedKey, outcomeKey }: PushOutcomeCoverageOptions<TSubmitted, TOutcome>,
58
+ ): readonly TOutcome[] {
59
+ const outcomeKeys = new Set(outcomes.map(outcomeKey));
60
+ const missing = new Set(submitted.map(submittedKey).filter((key) => !outcomeKeys.has(key)));
61
+ if (missing.size > 0) {
62
+ throw new SyncPayloadCompatibilityError(
63
+ `The push response omitted outcomes for ${String(missing.size)} submitted entities.`,
64
+ );
65
+ }
66
+ return outcomes;
67
+ }
68
+
69
+ /**
70
+ * Orders a push batch parent-before-child and coalesces per entity.
71
+ *
72
+ * Queued changes are grouped by `entityType:entityId`. Each group keeps the
73
+ * position of its first occurrence, so foreign-key order survives coalescing,
74
+ * and carries the newest change so the request sends current data. Groups are
75
+ * then sorted by dependency rank, ties broken by that first position, and the
76
+ * batch is truncated to `batchSize`.
77
+ *
78
+ * A parent whose children are unsent is held back rather than reordered: the
79
+ * server would otherwise see a complete parent while a required child is still
80
+ * missing.
81
+ */
82
+ export function rankPushBatch<T extends PushCandidate>(
83
+ pending: readonly T[],
84
+ { rank, batchSize, isHeldBack }: RankPushBatchOptions<T>,
85
+ ): RankedPushItem<T>[] {
86
+ const grouped = new Map<string, { change: T; coveredChangeIds: string[]; order: number }>();
87
+
88
+ pending.forEach((change, order) => {
89
+ const key = `${change.entityType}:${change.entityId}`;
90
+ const existing = grouped.get(key);
91
+ grouped.set(key, {
92
+ change,
93
+ coveredChangeIds: [...(existing?.coveredChangeIds ?? []), change.changeId],
94
+ order: existing?.order ?? order,
95
+ });
96
+ });
97
+
98
+ return [...grouped.values()]
99
+ .filter(({ change }) => !isHeldBack?.(change))
100
+ .sort(
101
+ (left, right) =>
102
+ rank(left.change.entityType) - rank(right.change.entityType) || left.order - right.order,
103
+ )
104
+ .slice(0, batchSize)
105
+ .map(({ change, coveredChangeIds }) => ({ change, coveredChangeIds }));
106
+ }
107
+
108
+ /**
109
+ * Builds a {@link RankPushBatchOptions.rank} function from an ordered list of
110
+ * entity types. Types absent from the list sort after every listed type.
111
+ */
112
+ export function dependencyRankByOrder(order: readonly string[]): (entityType: string) => number {
113
+ const ranks = new Map(order.map((entityType, index) => [entityType, index]));
114
+ return (entityType) => ranks.get(entityType) ?? order.length;
115
+ }