@syncular/core 0.1.2 → 0.2.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.
Files changed (231) hide show
  1. package/README.md +5 -19
  2. package/dist/blob-ref.d.ts +21 -0
  3. package/dist/blob-ref.js +97 -0
  4. package/dist/bytes.d.ts +45 -0
  5. package/dist/bytes.js +242 -0
  6. package/dist/canonical-json.d.ts +10 -0
  7. package/dist/canonical-json.js +18 -0
  8. package/dist/constants.d.ts +5 -0
  9. package/dist/constants.js +5 -0
  10. package/dist/crypto.d.ts +60 -0
  11. package/dist/crypto.js +251 -0
  12. package/dist/errors.d.ts +14 -0
  13. package/dist/errors.js +17 -0
  14. package/dist/frames.d.ts +24 -0
  15. package/dist/frames.js +42 -0
  16. package/dist/index.d.ts +18 -26
  17. package/dist/index.js +18 -43
  18. package/dist/message.d.ts +157 -0
  19. package/dist/message.js +786 -0
  20. package/dist/realtime.d.ts +92 -0
  21. package/dist/realtime.js +222 -0
  22. package/dist/render.d.ts +22 -0
  23. package/dist/render.js +273 -0
  24. package/dist/row-codec.d.ts +39 -0
  25. package/dist/row-codec.js +176 -0
  26. package/dist/segment.d.ts +18 -0
  27. package/dist/segment.js +105 -0
  28. package/dist/stream.d.ts +20 -0
  29. package/dist/stream.js +102 -0
  30. package/package.json +7 -59
  31. package/src/blob-ref.ts +135 -0
  32. package/src/bytes.ts +301 -0
  33. package/src/canonical-json.ts +20 -0
  34. package/src/constants.ts +7 -0
  35. package/src/crypto.ts +335 -0
  36. package/src/errors.ts +18 -0
  37. package/src/frames.ts +47 -0
  38. package/src/index.ts +18 -43
  39. package/src/message.ts +1024 -0
  40. package/src/realtime.ts +340 -0
  41. package/src/render.ts +325 -0
  42. package/src/row-codec.ts +265 -0
  43. package/src/segment.ts +150 -0
  44. package/src/stream.ts +128 -0
  45. package/dist/blobs.d.ts +0 -153
  46. package/dist/blobs.d.ts.map +0 -1
  47. package/dist/blobs.js +0 -47
  48. package/dist/blobs.js.map +0 -1
  49. package/dist/column-codecs.d.ts +0 -59
  50. package/dist/column-codecs.d.ts.map +0 -1
  51. package/dist/column-codecs.js +0 -159
  52. package/dist/column-codecs.js.map +0 -1
  53. package/dist/conflict.d.ts +0 -22
  54. package/dist/conflict.d.ts.map +0 -1
  55. package/dist/conflict.js +0 -81
  56. package/dist/conflict.js.map +0 -1
  57. package/dist/database.d.ts +0 -7
  58. package/dist/database.d.ts.map +0 -1
  59. package/dist/database.js +0 -14
  60. package/dist/database.js.map +0 -1
  61. package/dist/dialect.d.ts +0 -7
  62. package/dist/dialect.d.ts.map +0 -1
  63. package/dist/dialect.js +0 -2
  64. package/dist/dialect.js.map +0 -1
  65. package/dist/error-responses.d.ts +0 -393
  66. package/dist/error-responses.d.ts.map +0 -1
  67. package/dist/error-responses.js +0 -393
  68. package/dist/error-responses.js.map +0 -1
  69. package/dist/http/api-client.d.ts +0 -11
  70. package/dist/http/api-client.d.ts.map +0 -1
  71. package/dist/http/api-client.js +0 -30
  72. package/dist/http/api-client.js.map +0 -1
  73. package/dist/http/blob.d.ts +0 -6
  74. package/dist/http/blob.d.ts.map +0 -1
  75. package/dist/http/blob.js +0 -46
  76. package/dist/http/blob.js.map +0 -1
  77. package/dist/http/generated/api.d.ts +0 -5241
  78. package/dist/http/generated/api.d.ts.map +0 -1
  79. package/dist/http/generated/api.js +0 -6
  80. package/dist/http/generated/api.js.map +0 -1
  81. package/dist/http/index.d.ts +0 -18
  82. package/dist/http/index.d.ts.map +0 -1
  83. package/dist/http/index.js +0 -212
  84. package/dist/http/index.js.map +0 -1
  85. package/dist/http/shared.d.ts +0 -45
  86. package/dist/http/shared.d.ts.map +0 -1
  87. package/dist/http/shared.js +0 -122
  88. package/dist/http/shared.js.map +0 -1
  89. package/dist/http/transport-client.d.ts +0 -31
  90. package/dist/http/transport-client.d.ts.map +0 -1
  91. package/dist/http/transport-client.js +0 -193
  92. package/dist/http/transport-client.js.map +0 -1
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js.map +0 -1
  95. package/dist/kysely-column-codecs.d.ts +0 -31
  96. package/dist/kysely-column-codecs.d.ts.map +0 -1
  97. package/dist/kysely-column-codecs.js +0 -452
  98. package/dist/kysely-column-codecs.js.map +0 -1
  99. package/dist/logger.d.ts +0 -29
  100. package/dist/logger.d.ts.map +0 -1
  101. package/dist/logger.js +0 -26
  102. package/dist/logger.js.map +0 -1
  103. package/dist/proxy/index.d.ts +0 -54
  104. package/dist/proxy/index.d.ts.map +0 -1
  105. package/dist/proxy/index.js +0 -7
  106. package/dist/proxy/index.js.map +0 -1
  107. package/dist/schemas/blobs.d.ts +0 -82
  108. package/dist/schemas/blobs.d.ts.map +0 -1
  109. package/dist/schemas/blobs.js +0 -64
  110. package/dist/schemas/blobs.js.map +0 -1
  111. package/dist/schemas/common.d.ts +0 -100
  112. package/dist/schemas/common.d.ts.map +0 -1
  113. package/dist/schemas/common.js +0 -64
  114. package/dist/schemas/common.js.map +0 -1
  115. package/dist/schemas/index.d.ts +0 -7
  116. package/dist/schemas/index.d.ts.map +0 -1
  117. package/dist/schemas/index.js +0 -7
  118. package/dist/schemas/index.js.map +0 -1
  119. package/dist/schemas/sync.d.ts +0 -1065
  120. package/dist/schemas/sync.d.ts.map +0 -1
  121. package/dist/schemas/sync.js +0 -361
  122. package/dist/schemas/sync.js.map +0 -1
  123. package/dist/scopes/index.d.ts +0 -113
  124. package/dist/scopes/index.d.ts.map +0 -1
  125. package/dist/scopes/index.js +0 -103
  126. package/dist/scopes/index.js.map +0 -1
  127. package/dist/sentry.d.ts +0 -31
  128. package/dist/sentry.d.ts.map +0 -1
  129. package/dist/sentry.js +0 -147
  130. package/dist/sentry.js.map +0 -1
  131. package/dist/snapshot-chunks.d.ts +0 -110
  132. package/dist/snapshot-chunks.d.ts.map +0 -1
  133. package/dist/snapshot-chunks.js +0 -682
  134. package/dist/snapshot-chunks.js.map +0 -1
  135. package/dist/sync-packs.d.ts +0 -33
  136. package/dist/sync-packs.d.ts.map +0 -1
  137. package/dist/sync-packs.js +0 -821
  138. package/dist/sync-packs.js.map +0 -1
  139. package/dist/telemetry.d.ts +0 -114
  140. package/dist/telemetry.d.ts.map +0 -1
  141. package/dist/telemetry.js +0 -114
  142. package/dist/telemetry.js.map +0 -1
  143. package/dist/transforms.d.ts +0 -146
  144. package/dist/transforms.d.ts.map +0 -1
  145. package/dist/transforms.js +0 -155
  146. package/dist/transforms.js.map +0 -1
  147. package/dist/types.d.ts +0 -210
  148. package/dist/types.d.ts.map +0 -1
  149. package/dist/types.js +0 -20
  150. package/dist/types.js.map +0 -1
  151. package/dist/utils/bytes.d.ts +0 -4
  152. package/dist/utils/bytes.d.ts.map +0 -1
  153. package/dist/utils/bytes.js +0 -66
  154. package/dist/utils/bytes.js.map +0 -1
  155. package/dist/utils/compression.d.ts +0 -22
  156. package/dist/utils/compression.d.ts.map +0 -1
  157. package/dist/utils/compression.js +0 -129
  158. package/dist/utils/compression.js.map +0 -1
  159. package/dist/utils/crypto.d.ts +0 -13
  160. package/dist/utils/crypto.d.ts.map +0 -1
  161. package/dist/utils/crypto.js +0 -107
  162. package/dist/utils/crypto.js.map +0 -1
  163. package/dist/utils/id.d.ts +0 -3
  164. package/dist/utils/id.d.ts.map +0 -1
  165. package/dist/utils/id.js +0 -25
  166. package/dist/utils/id.js.map +0 -1
  167. package/dist/utils/index.d.ts +0 -10
  168. package/dist/utils/index.d.ts.map +0 -1
  169. package/dist/utils/index.js +0 -10
  170. package/dist/utils/index.js.map +0 -1
  171. package/dist/utils/internal-runtime.d.ts +0 -16
  172. package/dist/utils/internal-runtime.d.ts.map +0 -1
  173. package/dist/utils/internal-runtime.js +0 -14
  174. package/dist/utils/internal-runtime.js.map +0 -1
  175. package/dist/utils/object.d.ts +0 -2
  176. package/dist/utils/object.d.ts.map +0 -1
  177. package/dist/utils/object.js +0 -4
  178. package/dist/utils/object.js.map +0 -1
  179. package/dist/utils/realtime-change-index.d.ts +0 -9
  180. package/dist/utils/realtime-change-index.d.ts.map +0 -1
  181. package/dist/utils/realtime-change-index.js +0 -36
  182. package/dist/utils/realtime-change-index.js.map +0 -1
  183. package/dist/utils/realtime-connection-registry.d.ts +0 -40
  184. package/dist/utils/realtime-connection-registry.d.ts.map +0 -1
  185. package/dist/utils/realtime-connection-registry.js +0 -237
  186. package/dist/utils/realtime-connection-registry.js.map +0 -1
  187. package/dist/utils/table-registry.d.ts +0 -10
  188. package/dist/utils/table-registry.d.ts.map +0 -1
  189. package/dist/utils/table-registry.js +0 -54
  190. package/dist/utils/table-registry.js.map +0 -1
  191. package/dist/utils/url.d.ts +0 -3
  192. package/dist/utils/url.d.ts.map +0 -1
  193. package/dist/utils/url.js +0 -20
  194. package/dist/utils/url.js.map +0 -1
  195. package/src/blobs.ts +0 -219
  196. package/src/column-codecs.ts +0 -262
  197. package/src/conflict.ts +0 -92
  198. package/src/database.ts +0 -21
  199. package/src/dialect.ts +0 -8
  200. package/src/error-responses.ts +0 -423
  201. package/src/http/api-client.ts +0 -39
  202. package/src/http/blob.ts +0 -95
  203. package/src/http/generated/api.ts +0 -5241
  204. package/src/http/index.ts +0 -365
  205. package/src/http/shared.ts +0 -220
  206. package/src/http/transport-client.ts +0 -314
  207. package/src/kysely-column-codecs.ts +0 -585
  208. package/src/logger.ts +0 -38
  209. package/src/proxy/index.ts +0 -57
  210. package/src/schemas/blobs.ts +0 -110
  211. package/src/schemas/common.ts +0 -89
  212. package/src/schemas/index.ts +0 -7
  213. package/src/schemas/sync.ts +0 -517
  214. package/src/scopes/index.ts +0 -237
  215. package/src/sentry.ts +0 -200
  216. package/src/snapshot-chunks.ts +0 -964
  217. package/src/sync-packs.ts +0 -1096
  218. package/src/telemetry.ts +0 -243
  219. package/src/transforms.ts +0 -256
  220. package/src/types.ts +0 -267
  221. package/src/utils/bytes.ts +0 -75
  222. package/src/utils/compression.ts +0 -187
  223. package/src/utils/crypto.ts +0 -146
  224. package/src/utils/id.ts +0 -34
  225. package/src/utils/index.ts +0 -9
  226. package/src/utils/internal-runtime.ts +0 -39
  227. package/src/utils/object.ts +0 -3
  228. package/src/utils/realtime-change-index.ts +0 -44
  229. package/src/utils/realtime-connection-registry.ts +0 -286
  230. package/src/utils/table-registry.ts +0 -67
  231. package/src/utils/url.ts +0 -29
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Schema-IR row codec (SPEC.md §2.4).
3
+ *
4
+ * A row is a null bitmap of ceil(columnCount / 8) bytes (bit i set =
5
+ * column i is NULL; LSB-first within each byte, byte i/8), followed by the
6
+ * non-null values encoded positionally in schema-IR declaration order.
7
+ * Used for `COMMIT` change payloads, rows-segment row data, push operation
8
+ * payloads, and conflict `serverRow` values.
9
+ */
10
+ import { parseBlobRef } from './blob-ref.js';
11
+ import { ByteReader, ByteWriter } from './bytes.js';
12
+ import { DecodeError } from './errors.js';
13
+ /** Wire tags, unchanged from v1's binary-table-v1 assignment (SPEC.md §2.4). */
14
+ const TYPE_TO_TAG = {
15
+ string: 1,
16
+ integer: 2,
17
+ float: 3,
18
+ boolean: 4,
19
+ json: 5,
20
+ bytes: 6,
21
+ blob_ref: 7,
22
+ crdt: 8,
23
+ };
24
+ const TAG_TO_TYPE = new Map(Object.entries(TYPE_TO_TAG).map(([type, tag]) => [tag, type]));
25
+ export function columnTypeTag(type) {
26
+ return TYPE_TO_TAG[type];
27
+ }
28
+ export function columnTypeFromTag(tag) {
29
+ const type = TAG_TO_TYPE.get(tag);
30
+ if (type === undefined) {
31
+ throw new DecodeError('sync.invalid_request', `unknown column type tag ${tag}`);
32
+ }
33
+ return type;
34
+ }
35
+ function writeValue(writer, column, value) {
36
+ switch (column.type) {
37
+ case 'string':
38
+ case 'json':
39
+ case 'blob_ref':
40
+ if (typeof value !== 'string') {
41
+ throw new Error(`column ${column.name} (${column.type}) requires a string value`);
42
+ }
43
+ writer.str(value);
44
+ return;
45
+ case 'integer':
46
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
47
+ throw new Error(`column ${column.name} (integer) requires a safe integer value`);
48
+ }
49
+ writer.i64(value);
50
+ return;
51
+ case 'float':
52
+ if (typeof value !== 'number') {
53
+ throw new Error(`column ${column.name} (float) requires a number value`);
54
+ }
55
+ writer.f64(value);
56
+ return;
57
+ case 'boolean':
58
+ if (typeof value !== 'boolean') {
59
+ throw new Error(`column ${column.name} (boolean) requires a boolean value`);
60
+ }
61
+ writer.bool(value);
62
+ return;
63
+ case 'bytes':
64
+ case 'crdt':
65
+ // §2.4 tag 8: a `crdt` value is byte-for-byte a `bytes` value —
66
+ // opaque CRDT bytes (§5.10), no structural validation. `crdtType`
67
+ // selects the server merger and never touches the codec.
68
+ if (!(value instanceof Uint8Array)) {
69
+ throw new Error(`column ${column.name} (${column.type}) requires a Uint8Array value`);
70
+ }
71
+ writer.bytes(value);
72
+ return;
73
+ }
74
+ }
75
+ function readValue(reader, column) {
76
+ switch (column.type) {
77
+ case 'string':
78
+ return reader.str();
79
+ case 'json': {
80
+ // Conventions `json` MUST, applied at row-codec decode (SPEC.md §2.4
81
+ // tag 5): the value must parse as a JSON document; the raw string is
82
+ // preserved verbatim for round-trip fidelity.
83
+ const raw = reader.str();
84
+ try {
85
+ JSON.parse(raw);
86
+ }
87
+ catch {
88
+ throw new DecodeError('sync.invalid_request', `json column ${column.name} does not parse as a JSON document`);
89
+ }
90
+ return raw;
91
+ }
92
+ case 'blob_ref': {
93
+ // §2.4 tag 7: the value is a canonical BlobRef JSON document
94
+ // (§5.9.1). Validated at decode, same class as tag-5 json; the raw
95
+ // string is preserved verbatim for re-encoding.
96
+ const raw = reader.str();
97
+ parseBlobRef(raw);
98
+ return raw;
99
+ }
100
+ case 'integer':
101
+ return reader.i64();
102
+ case 'float':
103
+ return reader.f64();
104
+ case 'boolean':
105
+ return reader.bool();
106
+ case 'bytes':
107
+ case 'crdt':
108
+ // §2.4 tag 8: opaque bytes, decoded exactly like tag 6 (no parse).
109
+ return reader.bytes();
110
+ }
111
+ }
112
+ export function writeRow(writer, columns, values) {
113
+ if (values.length !== columns.length) {
114
+ throw new Error(`row value count ${values.length} does not match column count ${columns.length}`);
115
+ }
116
+ const bitmapLength = Math.ceil(columns.length / 8);
117
+ const bitmap = new Uint8Array(bitmapLength);
118
+ for (let i = 0; i < columns.length; i++) {
119
+ const column = columns[i];
120
+ if (column === undefined)
121
+ continue;
122
+ if (values[i] === null) {
123
+ if (!column.nullable) {
124
+ throw new Error(`column ${column.name} is not nullable`);
125
+ }
126
+ bitmap[i >> 3] = (bitmap[i >> 3] ?? 0) | (1 << (i & 7));
127
+ }
128
+ }
129
+ writer.raw(bitmap);
130
+ for (let i = 0; i < columns.length; i++) {
131
+ const column = columns[i];
132
+ const value = values[i];
133
+ if (column === undefined || value === null || value === undefined)
134
+ continue;
135
+ writeValue(writer, column, value);
136
+ }
137
+ }
138
+ export function readRow(reader, columns) {
139
+ const bitmapLength = Math.ceil(columns.length / 8);
140
+ const bitmap = reader.raw(bitmapLength);
141
+ for (let i = columns.length; i < bitmapLength * 8; i++) {
142
+ if ((((bitmap[i >> 3] ?? 0) >> (i & 7)) & 1) !== 0) {
143
+ throw new DecodeError('sync.invalid_request', 'non-zero padding bit in row null bitmap');
144
+ }
145
+ }
146
+ const values = [];
147
+ for (let i = 0; i < columns.length; i++) {
148
+ const column = columns[i];
149
+ if (column === undefined)
150
+ continue;
151
+ const isNull = (((bitmap[i >> 3] ?? 0) >> (i & 7)) & 1) !== 0;
152
+ if (isNull) {
153
+ if (!column.nullable) {
154
+ throw new DecodeError('sync.invalid_request', `null bit set for non-nullable column ${column.name}`);
155
+ }
156
+ values.push(null);
157
+ }
158
+ else {
159
+ values.push(readValue(reader, column));
160
+ }
161
+ }
162
+ return values;
163
+ }
164
+ /** Encode one standalone row (e.g. a push payload or conflict serverRow). */
165
+ export function encodeRow(columns, values) {
166
+ const writer = new ByteWriter();
167
+ writeRow(writer, columns, values);
168
+ return writer.finish();
169
+ }
170
+ /** Decode one standalone row; the bytes must contain exactly one row. */
171
+ export function decodeRow(columns, bytes) {
172
+ const reader = new ByteReader(bytes);
173
+ const values = readRow(reader, columns);
174
+ reader.expectFullyConsumed('row payload');
175
+ return values;
176
+ }
@@ -0,0 +1,18 @@
1
+ import { type RowColumn, type RowValue } from './row-codec.js';
2
+ export declare const ROWS_SEGMENT_MAGIC = "SSG2";
3
+ export declare const ROWS_SEGMENT_FORMAT_VERSION = 1;
4
+ /** One §5.2 row record: the row's `server_version` plus its values. */
5
+ export interface SegmentRow {
6
+ /** The row's `server_version` at `asOfCommitSeq` (§2.2); always ≥ 1. */
7
+ readonly serverVersion: number;
8
+ readonly values: readonly RowValue[];
9
+ }
10
+ export interface RowsSegment {
11
+ readonly table: string;
12
+ readonly schemaVersion: number;
13
+ readonly columns: readonly RowColumn[];
14
+ /** Row blocks in wire order; each block is applied in one transaction. */
15
+ readonly blocks: readonly (readonly SegmentRow[])[];
16
+ }
17
+ export declare function encodeRowsSegment(segment: RowsSegment): Uint8Array;
18
+ export declare function decodeRowsSegment(bytes: Uint8Array): RowsSegment;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SSG2 rows segments (SPEC.md §5.2) — the mandatory bootstrap format.
3
+ *
4
+ * A standalone binary container: magic, format version, flags, a column
5
+ * descriptor header (validation checksum, never inference), then
6
+ * self-delimiting row blocks terminated by a mandatory `rowCount = 0` end
7
+ * marker. Each row record carries the row's current `server_version`
8
+ * (`serverVersion`, ≥ 1) ahead of the row-codec bytes, so segment-applied
9
+ * rows participate in §6.2 conflict detection (§5.6).
10
+ */
11
+ import { ByteReader, ByteWriter, utf8Encode } from './bytes.js';
12
+ import { DecodeError } from './errors.js';
13
+ import { columnTypeFromTag, columnTypeTag, readRow, writeRow, } from './row-codec.js';
14
+ export const ROWS_SEGMENT_MAGIC = 'SSG2';
15
+ export const ROWS_SEGMENT_FORMAT_VERSION = 1;
16
+ const MAGIC_BYTES = utf8Encode(ROWS_SEGMENT_MAGIC);
17
+ export function encodeRowsSegment(segment) {
18
+ const writer = new ByteWriter();
19
+ writer.raw(MAGIC_BYTES);
20
+ writer.u16(ROWS_SEGMENT_FORMAT_VERSION);
21
+ writer.u16(0);
22
+ writer.str(segment.table);
23
+ writer.i32(segment.schemaVersion);
24
+ writer.u16(segment.columns.length);
25
+ for (const column of segment.columns) {
26
+ writer.str(column.name);
27
+ writer.u8(columnTypeTag(column.type));
28
+ writer.u8(column.nullable ? 1 : 0);
29
+ }
30
+ for (const block of segment.blocks) {
31
+ if (block.length === 0) {
32
+ throw new Error('a rows-segment block must contain at least one row');
33
+ }
34
+ const rowsWriter = new ByteWriter();
35
+ for (const row of block) {
36
+ if (row.serverVersion < 1) {
37
+ throw new Error(`a rows-segment row serverVersion must be >= 1, got ${row.serverVersion}`);
38
+ }
39
+ rowsWriter.i64(row.serverVersion);
40
+ writeRow(rowsWriter, segment.columns, row.values);
41
+ }
42
+ const rows = rowsWriter.finish();
43
+ writer.u32(block.length);
44
+ writer.u32(rows.length);
45
+ writer.raw(rows);
46
+ }
47
+ writer.u32(0);
48
+ return writer.finish();
49
+ }
50
+ export function decodeRowsSegment(bytes) {
51
+ const reader = new ByteReader(bytes);
52
+ const magic = reader.raw(4);
53
+ if (magic[0] !== MAGIC_BYTES[0] ||
54
+ magic[1] !== MAGIC_BYTES[1] ||
55
+ magic[2] !== MAGIC_BYTES[2] ||
56
+ magic[3] !== MAGIC_BYTES[3]) {
57
+ throw new DecodeError('sync.invalid_request', 'bad rows segment magic');
58
+ }
59
+ const formatVersion = reader.u16();
60
+ if (formatVersion !== ROWS_SEGMENT_FORMAT_VERSION) {
61
+ throw new DecodeError('sync.invalid_request', `unsupported rows segment format version ${formatVersion}`);
62
+ }
63
+ const flags = reader.u16();
64
+ if (flags !== 0) {
65
+ throw new DecodeError('sync.invalid_request', `non-zero rows segment flags 0x${flags.toString(16)}`);
66
+ }
67
+ const table = reader.str();
68
+ const schemaVersion = reader.i32();
69
+ const columnCount = reader.u16();
70
+ const columns = [];
71
+ for (let i = 0; i < columnCount; i++) {
72
+ const name = reader.str();
73
+ const type = columnTypeFromTag(reader.u8());
74
+ const columnFlags = reader.u8();
75
+ if ((columnFlags & 0xfe) !== 0) {
76
+ throw new DecodeError('sync.invalid_request', `reserved column flag bits set for column ${name}`);
77
+ }
78
+ columns.push({ name, type, nullable: columnFlags === 1 });
79
+ }
80
+ const blocks = [];
81
+ for (;;) {
82
+ if (reader.remaining < 4) {
83
+ throw new DecodeError('sync.invalid_request', 'rows segment truncated: missing end marker');
84
+ }
85
+ const rowCount = reader.u32();
86
+ if (rowCount === 0) {
87
+ reader.expectFullyConsumed('rows segment end marker');
88
+ break;
89
+ }
90
+ const byteLength = reader.u32();
91
+ const rowBytes = reader.raw(byteLength);
92
+ const blockReader = new ByteReader(rowBytes);
93
+ const block = [];
94
+ for (let i = 0; i < rowCount; i++) {
95
+ const serverVersion = blockReader.i64();
96
+ if (serverVersion < 1) {
97
+ throw new DecodeError('sync.invalid_request', `rows segment row serverVersion must be >= 1, got ${serverVersion}`);
98
+ }
99
+ block.push({ serverVersion, values: readRow(blockReader, columns) });
100
+ }
101
+ blockReader.expectFullyConsumed('rows block');
102
+ blocks.push(block);
103
+ }
104
+ return { table, schemaVersion, columns, blocks };
105
+ }
@@ -0,0 +1,20 @@
1
+ export interface ScannedMessage {
2
+ /** The complete envelope bytes (header through the END frame). */
3
+ readonly message: Uint8Array;
4
+ /** Byte count buffered PAST the END frame — a §8.7 stream MUST end
5
+ * exactly at the END frame's last byte, so any excess is a protocol
6
+ * violation (pipelining) for the caller to act on. */
7
+ readonly excess: number;
8
+ }
9
+ export declare class MessageStreamScanner {
10
+ #private;
11
+ /** True once any bytes were fed. */
12
+ get started(): boolean;
13
+ /**
14
+ * Feed one chunk. Returns the completed message once the END frame is
15
+ * fully buffered, `undefined` while more bytes are needed. Throws
16
+ * `DecodeError` on an invalid envelope header (connection-fatal per
17
+ * §8.7) and plain `Error` on use after completion.
18
+ */
19
+ push(chunk: Uint8Array): ScannedMessage | undefined;
20
+ }
package/dist/stream.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Incremental SSP2 message-stream scanner (SPEC.md §1.4, §8.7).
3
+ *
4
+ * The envelope grammar is self-delimiting — an 8-byte header, then
5
+ * length-prefixed frames until `END` — so a byte stream split across
6
+ * arbitrary chunk boundaries (WebSocket messages, §8.7) needs no
7
+ * reassembly protocol beyond concatenation plus this scanner: feed
8
+ * chunks, learn exactly where one complete envelope ends. Used by the
9
+ * server session (request assembly, pipelining detection), the client
10
+ * (response assembly), and the conformance harness (round attribution).
11
+ *
12
+ * The scanner validates only the 8-byte header (a stream whose header is
13
+ * not a valid SSP2 envelope has no findable end, §8.7 connection-fatal
14
+ * rule) and walks frame length prefixes; full decoding stays with
15
+ * `decodeMessage`.
16
+ */
17
+ import { utf8Encode } from './bytes.js';
18
+ import { PROTOCOL_WIRE_VERSION, SYNC_PACK_MAGIC } from './constants.js';
19
+ import { DecodeError } from './errors.js';
20
+ const MAGIC = utf8Encode(SYNC_PACK_MAGIC);
21
+ /** `END` frame type byte (§1.2). */
22
+ const END_FRAME_TYPE = 0x00;
23
+ export class MessageStreamScanner {
24
+ #buffer = new Uint8Array(1024);
25
+ #length = 0;
26
+ /** Parse cursor: start of the next frame header; -1 while the 8-byte
27
+ * envelope header is still incomplete. */
28
+ #offset = -1;
29
+ #complete = false;
30
+ /** True once any bytes were fed. */
31
+ get started() {
32
+ return this.#length > 0;
33
+ }
34
+ /**
35
+ * Feed one chunk. Returns the completed message once the END frame is
36
+ * fully buffered, `undefined` while more bytes are needed. Throws
37
+ * `DecodeError` on an invalid envelope header (connection-fatal per
38
+ * §8.7) and plain `Error` on use after completion.
39
+ */
40
+ push(chunk) {
41
+ if (this.#complete) {
42
+ throw new Error('MessageStreamScanner: message already complete');
43
+ }
44
+ this.#append(chunk);
45
+ if (this.#offset < 0) {
46
+ if (this.#length < 8)
47
+ return undefined;
48
+ this.#checkHeader();
49
+ this.#offset = 8;
50
+ }
51
+ for (;;) {
52
+ if (this.#length - this.#offset < 5)
53
+ return undefined;
54
+ const frameType = this.#buffer[this.#offset];
55
+ const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset + this.#offset + 1, 4);
56
+ const frameLength = view.getUint32(0, true);
57
+ const frameEnd = this.#offset + 5 + frameLength;
58
+ if (this.#length < frameEnd)
59
+ return undefined;
60
+ this.#offset = frameEnd;
61
+ if (frameType === END_FRAME_TYPE) {
62
+ this.#complete = true;
63
+ return {
64
+ message: this.#buffer.subarray(0, frameEnd),
65
+ excess: this.#length - frameEnd,
66
+ };
67
+ }
68
+ }
69
+ }
70
+ #append(chunk) {
71
+ const needed = this.#length + chunk.length;
72
+ if (needed > this.#buffer.length) {
73
+ let capacity = this.#buffer.length;
74
+ while (capacity < needed)
75
+ capacity *= 2;
76
+ const grown = new Uint8Array(capacity);
77
+ grown.set(this.#buffer.subarray(0, this.#length));
78
+ this.#buffer = grown;
79
+ }
80
+ this.#buffer.set(chunk, this.#length);
81
+ this.#length = needed;
82
+ }
83
+ #checkHeader() {
84
+ const b = this.#buffer;
85
+ if (b[0] !== MAGIC[0] ||
86
+ b[1] !== MAGIC[1] ||
87
+ b[2] !== MAGIC[2] ||
88
+ b[3] !== MAGIC[3]) {
89
+ throw new DecodeError('sync.invalid_request', 'bad envelope magic');
90
+ }
91
+ const wireVersion = b[4] | (b[5] << 8);
92
+ if (wireVersion !== PROTOCOL_WIRE_VERSION) {
93
+ throw new DecodeError('sync.invalid_request', `unsupported wireVersion ${wireVersion}`);
94
+ }
95
+ if (b[6] !== 0x01 && b[6] !== 0x02) {
96
+ throw new DecodeError('sync.invalid_request', `unknown msgKind byte 0x${b[6].toString(16)}`);
97
+ }
98
+ if (b[7] !== 0x00) {
99
+ throw new DecodeError('sync.invalid_request', `non-zero envelope flags 0x${b[7].toString(16)}`);
100
+ }
101
+ }
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/core",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",
@@ -20,12 +20,11 @@
20
20
  "database",
21
21
  "typescript"
22
22
  ],
23
- "private": false,
23
+ "type": "module",
24
24
  "sideEffects": false,
25
25
  "publishConfig": {
26
26
  "access": "public"
27
27
  },
28
- "type": "module",
29
28
  "exports": {
30
29
  ".": {
31
30
  "bun": "./src/index.ts",
@@ -34,69 +33,18 @@
34
33
  "types": "./dist/index.d.ts",
35
34
  "default": "./dist/index.js"
36
35
  }
37
- },
38
- "./http": {
39
- "bun": "./src/http/index.ts",
40
- "browser": "./src/http/index.ts",
41
- "import": {
42
- "types": "./dist/http/index.d.ts",
43
- "default": "./dist/http/index.js"
44
- }
45
- },
46
- "./http/blob": {
47
- "bun": "./src/http/blob.ts",
48
- "browser": "./src/http/blob.ts",
49
- "import": {
50
- "types": "./dist/http/blob.d.ts",
51
- "default": "./dist/http/blob.js"
52
- }
53
- },
54
- "./sentry": {
55
- "bun": "./src/sentry.ts",
56
- "browser": "./src/sentry.ts",
57
- "import": {
58
- "types": "./dist/sentry.d.ts",
59
- "default": "./dist/sentry.js"
60
- }
61
36
  }
62
37
  },
63
- "scripts": {
64
- "fixtures:protocol": "bun scripts/generate-protocol-fixtures.ts",
65
- "test": "bun test src/__tests__",
66
- "tsgo": "tsgo --noEmit",
67
- "build": "tsgo",
68
- "release": "syncular-publish",
69
- "generate:http-types": "openapi-typescript ../server/openapi.json -o src/http/generated/api.ts"
70
- },
71
- "peerDependencies": {
72
- "kysely": "^0.28.0",
73
- "zod": "^4.0.0",
74
- "openapi-fetch": "^0.17.0"
75
- },
76
- "devDependencies": {
77
- "@syncular/config": "0.0.0",
78
- "kysely": "*",
79
- "zod": "*",
80
- "openapi-fetch": "^0.17.0",
81
- "openapi-typescript": "^7.13.0"
82
- },
83
38
  "files": [
84
39
  "dist",
85
40
  "src",
41
+ "README.md",
86
42
  "!src/**/*.test.ts",
87
43
  "!src/**/*.test.tsx",
88
- "!src/**/__tests__/**",
89
- "!dist/**/*.test.*",
90
- "!dist/**/__tests__/**",
91
- "!test/**",
92
- "!tests/**"
44
+ "!dist/**/*.test.js",
45
+ "!dist/**/*.test.d.ts"
93
46
  ],
94
- "dependencies": {
95
- "fflate": "^0.8.2"
96
- },
97
- "peerDependenciesMeta": {
98
- "openapi-fetch": {
99
- "optional": true
100
- }
47
+ "scripts": {
48
+ "generate:vectors": "bun run scripts/generate-vectors.ts"
101
49
  }
102
50
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Canonical BlobRef documents (SPEC.md §5.9.1) — the value carried by a
3
+ * `blob_ref` column (§2.4 tag 7).
4
+ *
5
+ * A `blob_ref` value is a `str` holding a JSON document with a pinned shape
6
+ * and pinned key order: `blobId` (content address), `byteLength`, then the
7
+ * optional `mediaType` and `name`. On the wire and in the codec it is
8
+ * byte-for-byte a `str`, validated at decode exactly like `json` (tag 5)
9
+ * plus the shape check below; the raw string is preserved verbatim for
10
+ * re-encoding. Nothing here re-serializes a decoded value — round-trip
11
+ * fidelity is the same rule the `json` tag obeys.
12
+ */
13
+ import { DecodeError } from './errors';
14
+
15
+ /** The content-address prefix, identical in form to `segmentId` (§5.1). */
16
+ const BLOB_ID_RE = /^sha256:[0-9a-f]{64}$/;
17
+
18
+ export interface BlobRef {
19
+ /** `"sha256:" + lowercase-hex SHA-256` of the blob bytes (§5.9.1). */
20
+ readonly blobId: string;
21
+ /** Non-negative uncompressed size within the i64 safe-integer contract. */
22
+ readonly byteLength: number;
23
+ /** Advisory MIME type; never parsed or trusted by the server. */
24
+ readonly mediaType?: string;
25
+ /** Advisory display filename. */
26
+ readonly name?: string;
27
+ }
28
+
29
+ /** Canonical key order (§5.9.1): blobId, byteLength, mediaType, name. */
30
+ const CANONICAL_KEYS: readonly string[] = [
31
+ 'blobId',
32
+ 'byteLength',
33
+ 'mediaType',
34
+ 'name',
35
+ ];
36
+
37
+ /**
38
+ * Parse and validate a raw `blob_ref` string against the §5.9.1 shape. A
39
+ * failure is a row-codec decode error (`sync.invalid_request`), the same
40
+ * class as the tag-5 json parse failure — so codec and §11 rendering can
41
+ * never disagree.
42
+ */
43
+ export function parseBlobRef(raw: string): BlobRef {
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ } catch {
48
+ throw new DecodeError(
49
+ 'sync.invalid_request',
50
+ 'blob_ref value does not parse as a JSON document (§5.9.1)',
51
+ );
52
+ }
53
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
54
+ throw new DecodeError(
55
+ 'sync.invalid_request',
56
+ 'blob_ref value must be a JSON object (§5.9.1)',
57
+ );
58
+ }
59
+ const obj = parsed as Record<string, unknown>;
60
+ for (const key of Object.keys(obj)) {
61
+ if (!CANONICAL_KEYS.includes(key)) {
62
+ throw new DecodeError(
63
+ 'sync.invalid_request',
64
+ `blob_ref has unknown key ${JSON.stringify(key)} (§5.9.1)`,
65
+ );
66
+ }
67
+ }
68
+ const { blobId, byteLength, mediaType, name } = obj;
69
+ if (typeof blobId !== 'string' || !BLOB_ID_RE.test(blobId)) {
70
+ throw new DecodeError(
71
+ 'sync.invalid_request',
72
+ 'blob_ref.blobId must be "sha256:<64 hex>" (§5.9.1)',
73
+ );
74
+ }
75
+ if (
76
+ typeof byteLength !== 'number' ||
77
+ !Number.isSafeInteger(byteLength) ||
78
+ byteLength < 0
79
+ ) {
80
+ throw new DecodeError(
81
+ 'sync.invalid_request',
82
+ 'blob_ref.byteLength must be a non-negative safe integer (§5.9.1)',
83
+ );
84
+ }
85
+ if (mediaType !== undefined && typeof mediaType !== 'string') {
86
+ throw new DecodeError(
87
+ 'sync.invalid_request',
88
+ 'blob_ref.mediaType must be a string when present (§5.9.1)',
89
+ );
90
+ }
91
+ if (name !== undefined && typeof name !== 'string') {
92
+ throw new DecodeError(
93
+ 'sync.invalid_request',
94
+ 'blob_ref.name must be a string when present (§5.9.1)',
95
+ );
96
+ }
97
+ // Canonical key order: the raw string's keys, in appearance order, must be
98
+ // a prefix-consistent subsequence of CANONICAL_KEYS (present keys in the
99
+ // pinned order, absent keys omitted).
100
+ const presentInOrder = Object.keys(obj);
101
+ const expectedOrder = CANONICAL_KEYS.filter((k) => Object.hasOwn(obj, k));
102
+ if (presentInOrder.join(',') !== expectedOrder.join(',')) {
103
+ throw new DecodeError(
104
+ 'sync.invalid_request',
105
+ 'blob_ref keys are not in canonical order (§5.9.1)',
106
+ );
107
+ }
108
+ return {
109
+ blobId,
110
+ byteLength,
111
+ ...(mediaType !== undefined ? { mediaType } : {}),
112
+ ...(name !== undefined ? { name } : {}),
113
+ };
114
+ }
115
+
116
+ /** Serialize a BlobRef to its canonical string form (§5.9.1 key order). */
117
+ export function serializeBlobRef(ref: BlobRef): string {
118
+ const obj: Record<string, unknown> = {
119
+ blobId: ref.blobId,
120
+ byteLength: ref.byteLength,
121
+ };
122
+ if (ref.mediaType !== undefined) obj.mediaType = ref.mediaType;
123
+ if (ref.name !== undefined) obj.name = ref.name;
124
+ return JSON.stringify(obj);
125
+ }
126
+
127
+ /** True iff `raw` is a valid canonical BlobRef document (no throw). */
128
+ export function isBlobRef(raw: string): boolean {
129
+ try {
130
+ parseBlobRef(raw);
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }