@syncular/core 0.7.0 → 0.9.0

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/dist/frames.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare const FrameType: {
13
13
  readonly SEGMENT_INLINE: 21;
14
14
  readonly SUB_END: 22;
15
15
  readonly LEASE: 25;
16
+ readonly PUSH_RESULT_DETAILS: 27;
16
17
  readonly ERROR: 31;
17
18
  };
18
19
  export type FrameTypeName = keyof typeof FrameType;
package/dist/frames.js CHANGED
@@ -13,6 +13,7 @@ export const FrameType = {
13
13
  SEGMENT_INLINE: 0x15,
14
14
  SUB_END: 0x16,
15
15
  LEASE: 0x19,
16
+ PUSH_RESULT_DETAILS: 0x1b,
16
17
  ERROR: 0x1f,
17
18
  };
18
19
  /** Known request frame types (excluding END). */
@@ -32,6 +33,7 @@ export const RESPONSE_FRAME_TYPES = new Set([
32
33
  FrameType.SEGMENT_INLINE,
33
34
  FrameType.SUB_END,
34
35
  FrameType.LEASE,
36
+ FrameType.PUSH_RESULT_DETAILS,
35
37
  FrameType.ERROR,
36
38
  ]);
37
39
  /** Every frame type with a defined layout in wire version 1. */
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from './errors.js';
15
15
  export * from './frames.js';
16
16
  export * from './message.js';
17
17
  export * from './realtime.js';
18
+ export * from './rejection-details.js';
18
19
  export * from './render.js';
19
20
  export * from './row-codec.js';
20
21
  export * from './segment.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * from './errors.js';
15
15
  export * from './frames.js';
16
16
  export * from './message.js';
17
17
  export * from './realtime.js';
18
+ export * from './rejection-details.js';
18
19
  export * from './render.js';
19
20
  export * from './row-codec.js';
20
21
  export * from './segment.js';
package/dist/message.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type RejectionDetails } from './rejection-details.js';
1
2
  export type ScopeMap = Record<string, string[]>;
2
3
  export interface ReqHeaderFrame {
3
4
  type: 'REQ_HEADER';
@@ -70,6 +71,11 @@ export type PushOperationResult = {
70
71
  code: string;
71
72
  message: string;
72
73
  retryable: boolean;
74
+ /**
75
+ * Host-safe structured metadata. It is emitted on the wire through the
76
+ * additive PUSH_RESULT_DETAILS companion frame, not the legacy record.
77
+ */
78
+ details?: RejectionDetails;
73
79
  };
74
80
  export interface PushResultFrame {
75
81
  type: 'PUSH_RESULT';
@@ -79,6 +85,18 @@ export interface PushResultFrame {
79
85
  commitSeq?: number;
80
86
  results: PushOperationResult[];
81
87
  }
88
+ /**
89
+ * Additive companion metadata for host validation errors. Older clients see
90
+ * this as an unknown frame and continue processing the normative PUSH_RESULT.
91
+ */
92
+ export interface PushResultDetailsFrame {
93
+ type: 'PUSH_RESULT_DETAILS';
94
+ clientCommitId: string;
95
+ entries: Array<{
96
+ opIndex: number;
97
+ details: RejectionDetails;
98
+ }>;
99
+ }
82
100
  export interface SubStartFrame {
83
101
  type: 'SUB_START';
84
102
  id: string;
@@ -140,7 +158,7 @@ export interface ErrorFrame {
140
158
  recommendedAction: string;
141
159
  details?: string;
142
160
  }
143
- export type ResponseFrame = RespHeaderFrame | LeaseFrame | PushResultFrame | SubStartFrame | CommitFrame | SegmentRefFrame | SegmentInlineFrame | SubEndFrame | ErrorFrame | UnknownFrame;
161
+ export type ResponseFrame = RespHeaderFrame | LeaseFrame | PushResultFrame | PushResultDetailsFrame | SubStartFrame | CommitFrame | SegmentRefFrame | SegmentInlineFrame | SubEndFrame | ErrorFrame | UnknownFrame;
144
162
  export interface RequestMessage {
145
163
  wireVersion: number;
146
164
  msgKind: 'request';
package/dist/message.js CHANGED
@@ -15,6 +15,7 @@ import { ByteReader, ByteWriter, readStringListMap, readStringMap, utf8Encode, w
15
15
  import { PROTOCOL_WIRE_VERSION, SYNC_PACK_MAGIC } from './constants.js';
16
16
  import { DecodeError } from './errors.js';
17
17
  import { FrameType, KNOWN_FRAME_TYPES, REQUEST_FRAME_TYPES, RESPONSE_FRAME_TYPES, } from './frames.js';
18
+ import { normalizeRejectionDetails, } from './rejection-details.js';
18
19
  import { decodeRowsSegment } from './segment.js';
19
20
  const MAGIC_BYTES = utf8Encode(SYNC_PACK_MAGIC);
20
21
  function invalid(message) {
@@ -187,6 +188,33 @@ function decodePushResult(r) {
187
188
  results,
188
189
  };
189
190
  }
191
+ function decodePushResultDetails(r) {
192
+ const clientCommitId = r.str();
193
+ if (clientCommitId.length === 0) {
194
+ invalid('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
195
+ }
196
+ const count = r.u32();
197
+ if (count === 0)
198
+ invalid('PUSH_RESULT_DETAILS must carry at least one entry');
199
+ const entries = [];
200
+ const seen = new Set();
201
+ for (let index = 0; index < count; index++) {
202
+ const opIndex = r.i32();
203
+ if (opIndex < 0 || seen.has(opIndex)) {
204
+ invalid('PUSH_RESULT_DETAILS opIndex values must be unique and non-negative');
205
+ }
206
+ seen.add(opIndex);
207
+ let details;
208
+ try {
209
+ details = normalizeRejectionDetails(JSON.parse(r.str()));
210
+ }
211
+ catch (error) {
212
+ invalid(`invalid PUSH_RESULT_DETAILS.details: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ entries.push({ opIndex, details });
215
+ }
216
+ return { type: 'PUSH_RESULT_DETAILS', clientCommitId, entries };
217
+ }
190
218
  function decodeSubStart(r) {
191
219
  const id = r.str();
192
220
  const statusByte = r.u8();
@@ -363,6 +391,9 @@ function decodeResponseFrame(frameType, payload) {
363
391
  case FrameType.PUSH_RESULT:
364
392
  frame = decodePushResult(r);
365
393
  break;
394
+ case FrameType.PUSH_RESULT_DETAILS:
395
+ frame = decodePushResultDetails(r);
396
+ break;
366
397
  case FrameType.SUB_START:
367
398
  frame = decodeSubStart(r);
368
399
  break;
@@ -439,6 +470,8 @@ function validateResponseSequence(frames) {
439
470
  let subscriptionHasCommits = false;
440
471
  let subscriptionHasSegments = false;
441
472
  let errorSeen = false;
473
+ let lastPushResult;
474
+ let lastPushResultHasDetails = false;
442
475
  for (const frame of frames.slice(1)) {
443
476
  if (errorSeen)
444
477
  invalid('frames after ERROR (the next frame must be END)');
@@ -458,8 +491,32 @@ function validateResponseSequence(frames) {
458
491
  case 'PUSH_RESULT':
459
492
  if (sawSubscription)
460
493
  invalid('PUSH_RESULT frame after SUB_START');
494
+ lastPushResult = frame;
495
+ lastPushResultHasDetails = false;
496
+ sawBody = true;
497
+ break;
498
+ case 'PUSH_RESULT_DETAILS': {
499
+ if (sawSubscription) {
500
+ invalid('PUSH_RESULT_DETAILS frame after SUB_START');
501
+ }
502
+ if (lastPushResult === undefined) {
503
+ invalid('PUSH_RESULT_DETAILS without a preceding PUSH_RESULT');
504
+ }
505
+ if (lastPushResultHasDetails) {
506
+ invalid('duplicate PUSH_RESULT_DETAILS companion');
507
+ }
508
+ if (lastPushResult.clientCommitId !== frame.clientCommitId ||
509
+ lastPushResult.status !== 'rejected') {
510
+ invalid('PUSH_RESULT_DETAILS does not match its rejected PUSH_RESULT');
511
+ }
512
+ const errorIndexes = new Set(lastPushResult.results.flatMap((result) => result.status === 'error' ? [result.opIndex] : []));
513
+ if (frame.entries.some((entry) => !errorIndexes.has(entry.opIndex))) {
514
+ invalid('PUSH_RESULT_DETAILS entry does not match an error result');
515
+ }
516
+ lastPushResultHasDetails = true;
461
517
  sawBody = true;
462
518
  break;
519
+ }
463
520
  case 'SUB_START':
464
521
  if (inSubscription)
465
522
  invalid('nested SUB_START frame');
@@ -619,6 +676,32 @@ function encodeFrame(frame) {
619
676
  }
620
677
  return { frameType: FrameType.PUSH_RESULT, payload: w.finish() };
621
678
  }
679
+ case 'PUSH_RESULT_DETAILS': {
680
+ if (frame.clientCommitId.length === 0) {
681
+ throw new Error('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
682
+ }
683
+ if (frame.entries.length === 0) {
684
+ throw new Error('PUSH_RESULT_DETAILS must carry at least one entry');
685
+ }
686
+ const seen = new Set();
687
+ w.str(frame.clientCommitId);
688
+ w.u32(frame.entries.length);
689
+ for (const entry of frame.entries) {
690
+ if (!Number.isInteger(entry.opIndex) || entry.opIndex < 0) {
691
+ throw new Error('PUSH_RESULT_DETAILS.opIndex must be non-negative');
692
+ }
693
+ if (seen.has(entry.opIndex)) {
694
+ throw new Error('PUSH_RESULT_DETAILS.opIndex must be unique');
695
+ }
696
+ seen.add(entry.opIndex);
697
+ w.i32(entry.opIndex);
698
+ w.str(JSON.stringify(normalizeRejectionDetails(entry.details)));
699
+ }
700
+ return {
701
+ frameType: FrameType.PUSH_RESULT_DETAILS,
702
+ payload: w.finish(),
703
+ };
704
+ }
622
705
  case 'SUB_START': {
623
706
  w.str(frame.id);
624
707
  w.u8(SUB_STATUS_BYTES[frame.status]);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Bounded host rejection metadata carried by the additive
3
+ * `PUSH_RESULT_DETAILS` companion frame.
4
+ *
5
+ * Every member is deliberately code-like rather than free-form display text.
6
+ * A host opts values into replication by placing them here, so references MUST
7
+ * be non-sensitive identifiers that are safe for the authorized client to
8
+ * persist and render. Diagnostic prose stays in the ordinary `message` field.
9
+ */
10
+ export interface RejectionDetails {
11
+ /** Schema/domain paths affected by the rejection. */
12
+ readonly fieldPaths?: readonly string[];
13
+ /** Stable machine reason, for example `outside_schedule_day`. */
14
+ readonly reason?: string;
15
+ /** Stable recovery action, for example `edit_fields`. */
16
+ readonly requiredAction?: string;
17
+ /** Explicitly safe, non-sensitive identifiers needed by recovery UI. */
18
+ readonly references?: Readonly<Record<string, string>>;
19
+ }
20
+ export declare const REJECTION_DETAILS_LIMITS: {
21
+ readonly maxEncodedBytes: 4096;
22
+ readonly maxFieldPaths: 32;
23
+ readonly maxFieldPathLength: 160;
24
+ readonly maxTokenLength: 96;
25
+ readonly maxReferences: 16;
26
+ readonly maxReferenceKeyLength: 64;
27
+ readonly maxReferenceValueBytes: 256;
28
+ };
29
+ /** Validate, clone, and normalize a host-supplied details object. */
30
+ export declare function normalizeRejectionDetails(value: unknown): RejectionDetails;
@@ -0,0 +1,109 @@
1
+ export const REJECTION_DETAILS_LIMITS = {
2
+ maxEncodedBytes: 4_096,
3
+ maxFieldPaths: 32,
4
+ maxFieldPathLength: 160,
5
+ maxTokenLength: 96,
6
+ maxReferences: 16,
7
+ maxReferenceKeyLength: 64,
8
+ maxReferenceValueBytes: 256,
9
+ };
10
+ const FIELD_PATH = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
11
+ const TOKEN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
12
+ function hasControlCharacter(value) {
13
+ for (let index = 0; index < value.length; index++) {
14
+ const code = value.charCodeAt(index);
15
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
16
+ return true;
17
+ }
18
+ return false;
19
+ }
20
+ function recordOf(value) {
21
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
22
+ throw new Error('rejection details must be an object');
23
+ }
24
+ return value;
25
+ }
26
+ function stableToken(value, label, maxLength) {
27
+ if (typeof value !== 'string' ||
28
+ value.length === 0 ||
29
+ value.length > maxLength ||
30
+ !TOKEN.test(value)) {
31
+ throw new Error(`${label} must be a lowercase stable token of at most ${maxLength} characters`);
32
+ }
33
+ return value;
34
+ }
35
+ /** Validate, clone, and normalize a host-supplied details object. */
36
+ export function normalizeRejectionDetails(value) {
37
+ const source = recordOf(value);
38
+ const allowed = new Set([
39
+ 'fieldPaths',
40
+ 'reason',
41
+ 'requiredAction',
42
+ 'references',
43
+ ]);
44
+ for (const key of Object.keys(source)) {
45
+ if (!allowed.has(key)) {
46
+ throw new Error(`unknown rejection details member ${JSON.stringify(key)}`);
47
+ }
48
+ }
49
+ const normalized = {};
50
+ if (source.fieldPaths !== undefined) {
51
+ if (!Array.isArray(source.fieldPaths) ||
52
+ source.fieldPaths.length === 0 ||
53
+ source.fieldPaths.length > REJECTION_DETAILS_LIMITS.maxFieldPaths) {
54
+ throw new Error(`fieldPaths must contain 1-${REJECTION_DETAILS_LIMITS.maxFieldPaths} paths`);
55
+ }
56
+ const paths = [];
57
+ const seen = new Set();
58
+ for (const path of source.fieldPaths) {
59
+ if (typeof path !== 'string' ||
60
+ path.length === 0 ||
61
+ path.length > REJECTION_DETAILS_LIMITS.maxFieldPathLength ||
62
+ !FIELD_PATH.test(path)) {
63
+ throw new Error(`fieldPaths entries must be schema paths of at most ${REJECTION_DETAILS_LIMITS.maxFieldPathLength} characters`);
64
+ }
65
+ if (seen.has(path))
66
+ throw new Error(`duplicate field path ${JSON.stringify(path)}`);
67
+ seen.add(path);
68
+ paths.push(path);
69
+ }
70
+ normalized.fieldPaths = paths;
71
+ }
72
+ if (source.reason !== undefined) {
73
+ normalized.reason = stableToken(source.reason, 'reason', REJECTION_DETAILS_LIMITS.maxTokenLength);
74
+ }
75
+ if (source.requiredAction !== undefined) {
76
+ normalized.requiredAction = stableToken(source.requiredAction, 'requiredAction', REJECTION_DETAILS_LIMITS.maxTokenLength);
77
+ }
78
+ if (source.references !== undefined) {
79
+ const references = recordOf(source.references);
80
+ const keys = Object.keys(references).sort();
81
+ if (keys.length === 0 ||
82
+ keys.length > REJECTION_DETAILS_LIMITS.maxReferences) {
83
+ throw new Error(`references must contain 1-${REJECTION_DETAILS_LIMITS.maxReferences} entries`);
84
+ }
85
+ const safe = {};
86
+ for (const key of keys) {
87
+ stableToken(key, 'reference key', REJECTION_DETAILS_LIMITS.maxReferenceKeyLength);
88
+ const member = references[key];
89
+ if (typeof member !== 'string' ||
90
+ member.length === 0 ||
91
+ new TextEncoder().encode(member).length >
92
+ REJECTION_DETAILS_LIMITS.maxReferenceValueBytes ||
93
+ member.trim() !== member ||
94
+ hasControlCharacter(member)) {
95
+ throw new Error(`reference values must be non-empty safe strings of at most ${REJECTION_DETAILS_LIMITS.maxReferenceValueBytes} encoded bytes`);
96
+ }
97
+ safe[key] = member;
98
+ }
99
+ normalized.references = safe;
100
+ }
101
+ if (Object.keys(normalized).length === 0) {
102
+ throw new Error('rejection details must contain at least one supported member');
103
+ }
104
+ const encodedBytes = new TextEncoder().encode(JSON.stringify(normalized)).length;
105
+ if (encodedBytes > REJECTION_DETAILS_LIMITS.maxEncodedBytes) {
106
+ throw new Error(`rejection details exceed ${REJECTION_DETAILS_LIMITS.maxEncodedBytes} encoded bytes`);
107
+ }
108
+ return normalized;
109
+ }
package/dist/render.js CHANGED
@@ -151,6 +151,28 @@ function renderFrame(frame) {
151
151
  : {}),
152
152
  results: frame.results.map(renderResult),
153
153
  };
154
+ case 'PUSH_RESULT_DETAILS':
155
+ return {
156
+ type: 'PUSH_RESULT_DETAILS',
157
+ clientCommitId: frame.clientCommitId,
158
+ entries: frame.entries.map((entry) => ({
159
+ opIndex: entry.opIndex,
160
+ details: {
161
+ ...(entry.details.fieldPaths !== undefined
162
+ ? { fieldPaths: [...entry.details.fieldPaths] }
163
+ : {}),
164
+ ...(entry.details.reason !== undefined
165
+ ? { reason: entry.details.reason }
166
+ : {}),
167
+ ...(entry.details.requiredAction !== undefined
168
+ ? { requiredAction: entry.details.requiredAction }
169
+ : {}),
170
+ ...(entry.details.references !== undefined
171
+ ? { references: { ...entry.details.references } }
172
+ : {}),
173
+ },
174
+ })),
175
+ };
154
176
  case 'SUB_START':
155
177
  return {
156
178
  type: 'SUB_START',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Core protocol types and shared utilities for the Syncular sync framework",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
package/src/frames.ts CHANGED
@@ -13,6 +13,7 @@ export const FrameType = {
13
13
  SEGMENT_INLINE: 0x15,
14
14
  SUB_END: 0x16,
15
15
  LEASE: 0x19,
16
+ PUSH_RESULT_DETAILS: 0x1b,
16
17
  ERROR: 0x1f,
17
18
  } as const;
18
19
 
@@ -36,6 +37,7 @@ export const RESPONSE_FRAME_TYPES: ReadonlySet<number> = new Set([
36
37
  FrameType.SEGMENT_INLINE,
37
38
  FrameType.SUB_END,
38
39
  FrameType.LEASE,
40
+ FrameType.PUSH_RESULT_DETAILS,
39
41
  FrameType.ERROR,
40
42
  ]);
41
43
 
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export * from './errors';
15
15
  export * from './frames';
16
16
  export * from './message';
17
17
  export * from './realtime';
18
+ export * from './rejection-details';
18
19
  export * from './render';
19
20
  export * from './row-codec';
20
21
  export * from './segment';
package/src/message.ts CHANGED
@@ -28,6 +28,10 @@ import {
28
28
  REQUEST_FRAME_TYPES,
29
29
  RESPONSE_FRAME_TYPES,
30
30
  } from './frames';
31
+ import {
32
+ normalizeRejectionDetails,
33
+ type RejectionDetails,
34
+ } from './rejection-details';
31
35
  import { decodeRowsSegment } from './segment';
32
36
 
33
37
  const MAGIC_BYTES = utf8Encode(SYNC_PACK_MAGIC);
@@ -119,6 +123,11 @@ export type PushOperationResult =
119
123
  code: string;
120
124
  message: string;
121
125
  retryable: boolean;
126
+ /**
127
+ * Host-safe structured metadata. It is emitted on the wire through the
128
+ * additive PUSH_RESULT_DETAILS companion frame, not the legacy record.
129
+ */
130
+ details?: RejectionDetails;
122
131
  };
123
132
 
124
133
  export interface PushResultFrame {
@@ -130,6 +139,16 @@ export interface PushResultFrame {
130
139
  results: PushOperationResult[];
131
140
  }
132
141
 
142
+ /**
143
+ * Additive companion metadata for host validation errors. Older clients see
144
+ * this as an unknown frame and continue processing the normative PUSH_RESULT.
145
+ */
146
+ export interface PushResultDetailsFrame {
147
+ type: 'PUSH_RESULT_DETAILS';
148
+ clientCommitId: string;
149
+ entries: Array<{ opIndex: number; details: RejectionDetails }>;
150
+ }
151
+
133
152
  export interface SubStartFrame {
134
153
  type: 'SUB_START';
135
154
  id: string;
@@ -202,6 +221,7 @@ export type ResponseFrame =
202
221
  | RespHeaderFrame
203
222
  | LeaseFrame
204
223
  | PushResultFrame
224
+ | PushResultDetailsFrame
205
225
  | SubStartFrame
206
226
  | CommitFrame
207
227
  | SegmentRefFrame
@@ -397,6 +417,36 @@ function decodePushResult(r: ByteReader): PushResultFrame {
397
417
  };
398
418
  }
399
419
 
420
+ function decodePushResultDetails(r: ByteReader): PushResultDetailsFrame {
421
+ const clientCommitId = r.str();
422
+ if (clientCommitId.length === 0) {
423
+ invalid('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
424
+ }
425
+ const count = r.u32();
426
+ if (count === 0) invalid('PUSH_RESULT_DETAILS must carry at least one entry');
427
+ const entries: PushResultDetailsFrame['entries'] = [];
428
+ const seen = new Set<number>();
429
+ for (let index = 0; index < count; index++) {
430
+ const opIndex = r.i32();
431
+ if (opIndex < 0 || seen.has(opIndex)) {
432
+ invalid(
433
+ 'PUSH_RESULT_DETAILS opIndex values must be unique and non-negative',
434
+ );
435
+ }
436
+ seen.add(opIndex);
437
+ let details: RejectionDetails;
438
+ try {
439
+ details = normalizeRejectionDetails(JSON.parse(r.str()) as unknown);
440
+ } catch (error) {
441
+ invalid(
442
+ `invalid PUSH_RESULT_DETAILS.details: ${error instanceof Error ? error.message : String(error)}`,
443
+ );
444
+ }
445
+ entries.push({ opIndex, details });
446
+ }
447
+ return { type: 'PUSH_RESULT_DETAILS', clientCommitId, entries };
448
+ }
449
+
400
450
  function decodeSubStart(r: ByteReader): SubStartFrame {
401
451
  const id = r.str();
402
452
  const statusByte = r.u8();
@@ -581,6 +631,9 @@ function decodeResponseFrame(
581
631
  case FrameType.PUSH_RESULT:
582
632
  frame = decodePushResult(r);
583
633
  break;
634
+ case FrameType.PUSH_RESULT_DETAILS:
635
+ frame = decodePushResultDetails(r);
636
+ break;
584
637
  case FrameType.SUB_START:
585
638
  frame = decodeSubStart(r);
586
639
  break;
@@ -659,6 +712,8 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
659
712
  let subscriptionHasCommits = false;
660
713
  let subscriptionHasSegments = false;
661
714
  let errorSeen = false;
715
+ let lastPushResult: PushResultFrame | undefined;
716
+ let lastPushResultHasDetails = false;
662
717
  for (const frame of frames.slice(1)) {
663
718
  if (errorSeen) invalid('frames after ERROR (the next frame must be END)');
664
719
  switch (frame.type) {
@@ -674,8 +729,40 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
674
729
  break;
675
730
  case 'PUSH_RESULT':
676
731
  if (sawSubscription) invalid('PUSH_RESULT frame after SUB_START');
732
+ lastPushResult = frame;
733
+ lastPushResultHasDetails = false;
677
734
  sawBody = true;
678
735
  break;
736
+ case 'PUSH_RESULT_DETAILS': {
737
+ if (sawSubscription) {
738
+ invalid('PUSH_RESULT_DETAILS frame after SUB_START');
739
+ }
740
+ if (lastPushResult === undefined) {
741
+ invalid('PUSH_RESULT_DETAILS without a preceding PUSH_RESULT');
742
+ }
743
+ if (lastPushResultHasDetails) {
744
+ invalid('duplicate PUSH_RESULT_DETAILS companion');
745
+ }
746
+ if (
747
+ lastPushResult.clientCommitId !== frame.clientCommitId ||
748
+ lastPushResult.status !== 'rejected'
749
+ ) {
750
+ invalid(
751
+ 'PUSH_RESULT_DETAILS does not match its rejected PUSH_RESULT',
752
+ );
753
+ }
754
+ const errorIndexes = new Set(
755
+ lastPushResult.results.flatMap((result) =>
756
+ result.status === 'error' ? [result.opIndex] : [],
757
+ ),
758
+ );
759
+ if (frame.entries.some((entry) => !errorIndexes.has(entry.opIndex))) {
760
+ invalid('PUSH_RESULT_DETAILS entry does not match an error result');
761
+ }
762
+ lastPushResultHasDetails = true;
763
+ sawBody = true;
764
+ break;
765
+ }
679
766
  case 'SUB_START':
680
767
  if (inSubscription) invalid('nested SUB_START frame');
681
768
  inSubscription = true;
@@ -838,6 +925,32 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
838
925
  }
839
926
  return { frameType: FrameType.PUSH_RESULT, payload: w.finish() };
840
927
  }
928
+ case 'PUSH_RESULT_DETAILS': {
929
+ if (frame.clientCommitId.length === 0) {
930
+ throw new Error('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
931
+ }
932
+ if (frame.entries.length === 0) {
933
+ throw new Error('PUSH_RESULT_DETAILS must carry at least one entry');
934
+ }
935
+ const seen = new Set<number>();
936
+ w.str(frame.clientCommitId);
937
+ w.u32(frame.entries.length);
938
+ for (const entry of frame.entries) {
939
+ if (!Number.isInteger(entry.opIndex) || entry.opIndex < 0) {
940
+ throw new Error('PUSH_RESULT_DETAILS.opIndex must be non-negative');
941
+ }
942
+ if (seen.has(entry.opIndex)) {
943
+ throw new Error('PUSH_RESULT_DETAILS.opIndex must be unique');
944
+ }
945
+ seen.add(entry.opIndex);
946
+ w.i32(entry.opIndex);
947
+ w.str(JSON.stringify(normalizeRejectionDetails(entry.details)));
948
+ }
949
+ return {
950
+ frameType: FrameType.PUSH_RESULT_DETAILS,
951
+ payload: w.finish(),
952
+ };
953
+ }
841
954
  case 'SUB_START': {
842
955
  w.str(frame.id);
843
956
  w.u8(SUB_STATUS_BYTES[frame.status]);
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Bounded host rejection metadata carried by the additive
3
+ * `PUSH_RESULT_DETAILS` companion frame.
4
+ *
5
+ * Every member is deliberately code-like rather than free-form display text.
6
+ * A host opts values into replication by placing them here, so references MUST
7
+ * be non-sensitive identifiers that are safe for the authorized client to
8
+ * persist and render. Diagnostic prose stays in the ordinary `message` field.
9
+ */
10
+ export interface RejectionDetails {
11
+ /** Schema/domain paths affected by the rejection. */
12
+ readonly fieldPaths?: readonly string[];
13
+ /** Stable machine reason, for example `outside_schedule_day`. */
14
+ readonly reason?: string;
15
+ /** Stable recovery action, for example `edit_fields`. */
16
+ readonly requiredAction?: string;
17
+ /** Explicitly safe, non-sensitive identifiers needed by recovery UI. */
18
+ readonly references?: Readonly<Record<string, string>>;
19
+ }
20
+
21
+ export const REJECTION_DETAILS_LIMITS = {
22
+ maxEncodedBytes: 4_096,
23
+ maxFieldPaths: 32,
24
+ maxFieldPathLength: 160,
25
+ maxTokenLength: 96,
26
+ maxReferences: 16,
27
+ maxReferenceKeyLength: 64,
28
+ maxReferenceValueBytes: 256,
29
+ } as const;
30
+
31
+ const FIELD_PATH = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
32
+ const TOKEN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
33
+
34
+ function hasControlCharacter(value: string): boolean {
35
+ for (let index = 0; index < value.length; index++) {
36
+ const code = value.charCodeAt(index);
37
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ function recordOf(value: unknown): Record<string, unknown> {
43
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
44
+ throw new Error('rejection details must be an object');
45
+ }
46
+ return value as Record<string, unknown>;
47
+ }
48
+
49
+ function stableToken(value: unknown, label: string, maxLength: number): string {
50
+ if (
51
+ typeof value !== 'string' ||
52
+ value.length === 0 ||
53
+ value.length > maxLength ||
54
+ !TOKEN.test(value)
55
+ ) {
56
+ throw new Error(
57
+ `${label} must be a lowercase stable token of at most ${maxLength} characters`,
58
+ );
59
+ }
60
+ return value;
61
+ }
62
+
63
+ /** Validate, clone, and normalize a host-supplied details object. */
64
+ export function normalizeRejectionDetails(value: unknown): RejectionDetails {
65
+ const source = recordOf(value);
66
+ const allowed = new Set([
67
+ 'fieldPaths',
68
+ 'reason',
69
+ 'requiredAction',
70
+ 'references',
71
+ ]);
72
+ for (const key of Object.keys(source)) {
73
+ if (!allowed.has(key)) {
74
+ throw new Error(
75
+ `unknown rejection details member ${JSON.stringify(key)}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ const normalized: {
81
+ fieldPaths?: string[];
82
+ reason?: string;
83
+ requiredAction?: string;
84
+ references?: Record<string, string>;
85
+ } = {};
86
+
87
+ if (source.fieldPaths !== undefined) {
88
+ if (
89
+ !Array.isArray(source.fieldPaths) ||
90
+ source.fieldPaths.length === 0 ||
91
+ source.fieldPaths.length > REJECTION_DETAILS_LIMITS.maxFieldPaths
92
+ ) {
93
+ throw new Error(
94
+ `fieldPaths must contain 1-${REJECTION_DETAILS_LIMITS.maxFieldPaths} paths`,
95
+ );
96
+ }
97
+ const paths: string[] = [];
98
+ const seen = new Set<string>();
99
+ for (const path of source.fieldPaths) {
100
+ if (
101
+ typeof path !== 'string' ||
102
+ path.length === 0 ||
103
+ path.length > REJECTION_DETAILS_LIMITS.maxFieldPathLength ||
104
+ !FIELD_PATH.test(path)
105
+ ) {
106
+ throw new Error(
107
+ `fieldPaths entries must be schema paths of at most ${REJECTION_DETAILS_LIMITS.maxFieldPathLength} characters`,
108
+ );
109
+ }
110
+ if (seen.has(path))
111
+ throw new Error(`duplicate field path ${JSON.stringify(path)}`);
112
+ seen.add(path);
113
+ paths.push(path);
114
+ }
115
+ normalized.fieldPaths = paths;
116
+ }
117
+
118
+ if (source.reason !== undefined) {
119
+ normalized.reason = stableToken(
120
+ source.reason,
121
+ 'reason',
122
+ REJECTION_DETAILS_LIMITS.maxTokenLength,
123
+ );
124
+ }
125
+ if (source.requiredAction !== undefined) {
126
+ normalized.requiredAction = stableToken(
127
+ source.requiredAction,
128
+ 'requiredAction',
129
+ REJECTION_DETAILS_LIMITS.maxTokenLength,
130
+ );
131
+ }
132
+
133
+ if (source.references !== undefined) {
134
+ const references = recordOf(source.references);
135
+ const keys = Object.keys(references).sort();
136
+ if (
137
+ keys.length === 0 ||
138
+ keys.length > REJECTION_DETAILS_LIMITS.maxReferences
139
+ ) {
140
+ throw new Error(
141
+ `references must contain 1-${REJECTION_DETAILS_LIMITS.maxReferences} entries`,
142
+ );
143
+ }
144
+ const safe: Record<string, string> = {};
145
+ for (const key of keys) {
146
+ stableToken(
147
+ key,
148
+ 'reference key',
149
+ REJECTION_DETAILS_LIMITS.maxReferenceKeyLength,
150
+ );
151
+ const member = references[key];
152
+ if (
153
+ typeof member !== 'string' ||
154
+ member.length === 0 ||
155
+ new TextEncoder().encode(member).length >
156
+ REJECTION_DETAILS_LIMITS.maxReferenceValueBytes ||
157
+ member.trim() !== member ||
158
+ hasControlCharacter(member)
159
+ ) {
160
+ throw new Error(
161
+ `reference values must be non-empty safe strings of at most ${REJECTION_DETAILS_LIMITS.maxReferenceValueBytes} encoded bytes`,
162
+ );
163
+ }
164
+ safe[key] = member;
165
+ }
166
+ normalized.references = safe;
167
+ }
168
+
169
+ if (Object.keys(normalized).length === 0) {
170
+ throw new Error(
171
+ 'rejection details must contain at least one supported member',
172
+ );
173
+ }
174
+ const encodedBytes = new TextEncoder().encode(
175
+ JSON.stringify(normalized),
176
+ ).length;
177
+ if (encodedBytes > REJECTION_DETAILS_LIMITS.maxEncodedBytes) {
178
+ throw new Error(
179
+ `rejection details exceed ${REJECTION_DETAILS_LIMITS.maxEncodedBytes} encoded bytes`,
180
+ );
181
+ }
182
+ return normalized;
183
+ }
package/src/render.ts CHANGED
@@ -192,6 +192,28 @@ function renderFrame(frame: RequestFrame | ResponseFrame): JsonValue {
192
192
  : {}),
193
193
  results: frame.results.map(renderResult),
194
194
  };
195
+ case 'PUSH_RESULT_DETAILS':
196
+ return {
197
+ type: 'PUSH_RESULT_DETAILS',
198
+ clientCommitId: frame.clientCommitId,
199
+ entries: frame.entries.map((entry) => ({
200
+ opIndex: entry.opIndex,
201
+ details: {
202
+ ...(entry.details.fieldPaths !== undefined
203
+ ? { fieldPaths: [...entry.details.fieldPaths] }
204
+ : {}),
205
+ ...(entry.details.reason !== undefined
206
+ ? { reason: entry.details.reason }
207
+ : {}),
208
+ ...(entry.details.requiredAction !== undefined
209
+ ? { requiredAction: entry.details.requiredAction }
210
+ : {}),
211
+ ...(entry.details.references !== undefined
212
+ ? { references: { ...entry.details.references } }
213
+ : {}),
214
+ },
215
+ })),
216
+ };
195
217
  case 'SUB_START':
196
218
  return {
197
219
  type: 'SUB_START',