@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 +1 -0
- package/dist/frames.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/message.d.ts +19 -1
- package/dist/message.js +83 -0
- package/dist/rejection-details.d.ts +30 -0
- package/dist/rejection-details.js +109 -0
- package/dist/render.js +22 -0
- package/package.json +1 -1
- package/src/frames.ts +2 -0
- package/src/index.ts +1 -0
- package/src/message.ts +113 -0
- package/src/rejection-details.ts +183 -0
- package/src/render.ts +22 -0
package/dist/frames.d.ts
CHANGED
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
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
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',
|