@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,340 @@
1
+ /**
2
+ * Realtime JSON control messages and channel tags (SPEC.md §8).
3
+ *
4
+ * Control messages are JSON text frames; binary frames carry a one-byte
5
+ * channel tag (§8.7) followed by either a complete SSP2 response message
6
+ * (deltas) or a chunk of an in-flight sync round's byte stream, handled
7
+ * by the message codec / stream scanner. Per §8.1 a client MUST tolerate
8
+ * and ignore unknown JSON control events, so parsing returns
9
+ * `known: false` for unrecognized event names instead of failing.
10
+ */
11
+ import { DecodeError } from './errors';
12
+
13
+ /** §8.7 channel tag: a standalone SSP2 response message — a delta. */
14
+ export const REALTIME_TAG_DELTA = 0x00;
15
+ /** §8.7 channel tag: one chunk of the in-flight sync round's byte
16
+ * stream (request client→server, response server→client). */
17
+ export const REALTIME_TAG_ROUND = 0x01;
18
+
19
+ export interface RealtimeHelloEvent {
20
+ event: 'hello';
21
+ data: {
22
+ protocolVersion: number;
23
+ sessionId: string;
24
+ actorId: string;
25
+ clientId: string;
26
+ cursor: number;
27
+ latestCursor: number;
28
+ requiresSync: boolean;
29
+ timestamp: number;
30
+ };
31
+ }
32
+
33
+ export type WakeReason =
34
+ | 'delta-too-large'
35
+ | 'catchup-required'
36
+ | 'reset-required';
37
+
38
+ export interface RealtimeSyncEvent {
39
+ event: 'sync';
40
+ data: {
41
+ cursor: number;
42
+ requiresPull: true;
43
+ reason: WakeReason;
44
+ timestamp: number;
45
+ };
46
+ }
47
+
48
+ export interface RealtimeHeartbeatEvent {
49
+ event: 'heartbeat';
50
+ data: { timestamp: number };
51
+ }
52
+
53
+ /** §8.6.2 presence fanout kind — a closed set of three. */
54
+ export type PresenceKind = 'join' | 'update' | 'leave';
55
+
56
+ const PRESENCE_KINDS: readonly PresenceKind[] = ['join', 'update', 'leave'];
57
+
58
+ /**
59
+ * Server → client presence fanout (§8.6.2): a scope-mate's presence for a
60
+ * key the receiver holds changed. `doc` is the peer's document for
61
+ * `join`/`update` and `null` for `leave`. An `error` variant carries a
62
+ * client-runtime `presence.*` code back to the publisher (size cap /
63
+ * forbidden, §8.6.2); it never fans out to peers.
64
+ */
65
+ export interface RealtimePresenceEvent {
66
+ event: 'presence';
67
+ data: {
68
+ scopeKey: string;
69
+ kind?: PresenceKind;
70
+ actorId?: string;
71
+ clientId?: string;
72
+ doc?: Record<string, unknown> | null;
73
+ error?: string;
74
+ timestamp?: number;
75
+ };
76
+ }
77
+
78
+ export type RealtimeServerEvent =
79
+ | RealtimeHelloEvent
80
+ | RealtimeSyncEvent
81
+ | RealtimeHeartbeatEvent
82
+ | RealtimePresenceEvent;
83
+
84
+ /** Client → server delta acknowledgement (§8.2). */
85
+ export interface RealtimeAck {
86
+ type: 'ack';
87
+ cursor: number;
88
+ }
89
+
90
+ /** Client → server presence publish/leave (§8.6.2). `doc: null` = leave. */
91
+ export interface RealtimePresencePublish {
92
+ event: 'presence';
93
+ data: {
94
+ scopeKey: string;
95
+ doc: Record<string, unknown> | null;
96
+ };
97
+ }
98
+
99
+ export type ParsedRealtimeEvent =
100
+ | { known: true; event: RealtimeServerEvent }
101
+ | { known: false; eventName: string };
102
+
103
+ /** Serialize a client→server presence publish/leave (§8.6.2). */
104
+ export function encodePresencePublish(
105
+ scopeKey: string,
106
+ doc: Record<string, unknown> | null,
107
+ ): string {
108
+ return JSON.stringify({ event: 'presence', data: { scopeKey, doc } });
109
+ }
110
+
111
+ /** Serialize a server→client presence fanout event (§8.6.2). */
112
+ export function encodePresenceFanout(
113
+ scopeKey: string,
114
+ kind: PresenceKind,
115
+ actorId: string,
116
+ clientId: string,
117
+ doc: Record<string, unknown> | null,
118
+ timestamp: number,
119
+ ): string {
120
+ return JSON.stringify({
121
+ event: 'presence',
122
+ data: { scopeKey, kind, actorId, clientId, doc, timestamp },
123
+ });
124
+ }
125
+
126
+ /** Serialize a server→client presence error directed at the publisher
127
+ * (§8.6.2 `presence.too_large` / `presence.forbidden`). */
128
+ export function encodePresenceError(
129
+ scopeKey: string,
130
+ error: string,
131
+ timestamp: number,
132
+ ): string {
133
+ return JSON.stringify({
134
+ event: 'presence',
135
+ data: { scopeKey, error, timestamp },
136
+ });
137
+ }
138
+
139
+ function malformed(what: string): never {
140
+ throw new DecodeError(
141
+ 'sync.invalid_request',
142
+ `malformed realtime control message: ${what}`,
143
+ );
144
+ }
145
+
146
+ function requireObject(value: unknown, what: string): Record<string, unknown> {
147
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
148
+ malformed(`${what} must be a JSON object`);
149
+ }
150
+ return value as Record<string, unknown>;
151
+ }
152
+
153
+ function requireNumber(value: unknown, what: string): number {
154
+ // Realtime numeric fields are integers within the ±(2^53−1) i64 contract
155
+ // (SPEC.md §8.1); fractional or non-finite numbers are malformed.
156
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
157
+ malformed(`${what} must be an integer within the i64 safe range`);
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function requireString(value: unknown, what: string): string {
163
+ if (typeof value !== 'string') malformed(`${what} must be a string`);
164
+ return value;
165
+ }
166
+
167
+ function requireBoolean(value: unknown, what: string): boolean {
168
+ if (typeof value !== 'boolean') malformed(`${what} must be a boolean`);
169
+ return value;
170
+ }
171
+
172
+ const WAKE_REASONS: readonly WakeReason[] = [
173
+ 'delta-too-large',
174
+ 'catchup-required',
175
+ 'reset-required',
176
+ ];
177
+
178
+ export function parseRealtimeServerEvent(text: string): ParsedRealtimeEvent {
179
+ let value: unknown;
180
+ try {
181
+ value = JSON.parse(text);
182
+ } catch {
183
+ malformed('not valid JSON');
184
+ }
185
+ const root = requireObject(value, 'control message');
186
+ const event = requireString(root.event, 'event');
187
+ if (event === 'hello') {
188
+ const data = requireObject(root.data, 'hello.data');
189
+ return {
190
+ known: true,
191
+ event: {
192
+ event: 'hello',
193
+ data: {
194
+ protocolVersion: requireNumber(
195
+ data.protocolVersion,
196
+ 'hello.data.protocolVersion',
197
+ ),
198
+ sessionId: requireString(data.sessionId, 'hello.data.sessionId'),
199
+ actorId: requireString(data.actorId, 'hello.data.actorId'),
200
+ clientId: requireString(data.clientId, 'hello.data.clientId'),
201
+ cursor: requireNumber(data.cursor, 'hello.data.cursor'),
202
+ latestCursor: requireNumber(
203
+ data.latestCursor,
204
+ 'hello.data.latestCursor',
205
+ ),
206
+ requiresSync: requireBoolean(
207
+ data.requiresSync,
208
+ 'hello.data.requiresSync',
209
+ ),
210
+ timestamp: requireNumber(data.timestamp, 'hello.data.timestamp'),
211
+ },
212
+ },
213
+ };
214
+ }
215
+ if (event === 'sync') {
216
+ const data = requireObject(root.data, 'sync.data');
217
+ const reason = requireString(data.reason, 'sync.data.reason');
218
+ if (!(WAKE_REASONS as readonly string[]).includes(reason)) {
219
+ malformed(`unknown wake reason ${JSON.stringify(reason)}`);
220
+ }
221
+ if (data.requiresPull !== true) {
222
+ malformed('sync.data.requiresPull must be true');
223
+ }
224
+ return {
225
+ known: true,
226
+ event: {
227
+ event: 'sync',
228
+ data: {
229
+ cursor: requireNumber(data.cursor, 'sync.data.cursor'),
230
+ requiresPull: true,
231
+ reason: reason as WakeReason,
232
+ timestamp: requireNumber(data.timestamp, 'sync.data.timestamp'),
233
+ },
234
+ },
235
+ };
236
+ }
237
+ if (event === 'heartbeat') {
238
+ const data = requireObject(root.data, 'heartbeat.data');
239
+ return {
240
+ known: true,
241
+ event: {
242
+ event: 'heartbeat',
243
+ data: {
244
+ timestamp: requireNumber(data.timestamp, 'heartbeat.data.timestamp'),
245
+ },
246
+ },
247
+ };
248
+ }
249
+ if (event === 'presence') {
250
+ // §8.6.2: validate the presence shape but carry `data` through
251
+ // verbatim so re-encoding is byte-identical (host-defined `doc`). A
252
+ // `presence` message is recognized in both directions: a fanout (has
253
+ // `kind`), a publisher-directed error (has `error`), or a client→server
254
+ // publish (neither — just `scopeKey`/`doc`, §8.6.2).
255
+ const data = requireObject(root.data, 'presence.data');
256
+ validatePresenceScopeKey(data.scopeKey);
257
+ if ('error' in data) {
258
+ // The publisher-directed error variant (`presence.too_large` /
259
+ // `presence.forbidden`, §8.6.2) — a string code, no `kind`/`doc`.
260
+ requireString(data.error, 'presence.data.error');
261
+ } else if ('kind' in data) {
262
+ validatePresenceKind(data.kind);
263
+ requireString(data.actorId, 'presence.data.actorId');
264
+ requireString(data.clientId, 'presence.data.clientId');
265
+ validatePresenceFanoutDoc(data.kind as PresenceKind, data.doc);
266
+ } else {
267
+ // Client→server publish/leave: scopeKey + doc (object or null).
268
+ if (data.doc !== null) requirePresenceDocObject(data.doc);
269
+ }
270
+ if (data.timestamp !== undefined) {
271
+ requireNumber(data.timestamp, 'presence.data.timestamp');
272
+ }
273
+ return {
274
+ known: true,
275
+ event: { event: 'presence', data: data as RealtimePresenceEvent['data'] },
276
+ };
277
+ }
278
+ return { known: false, eventName: event };
279
+ }
280
+
281
+ function validatePresenceScopeKey(value: unknown): string {
282
+ const key = requireString(value, 'presence.data.scopeKey');
283
+ if (key.length === 0) malformed('presence.data.scopeKey must be non-empty');
284
+ return key;
285
+ }
286
+
287
+ function validatePresenceKind(value: unknown): void {
288
+ const kind = requireString(value, 'presence.data.kind');
289
+ if (!(PRESENCE_KINDS as readonly string[]).includes(kind)) {
290
+ malformed(`unknown presence kind ${JSON.stringify(kind)}`);
291
+ }
292
+ }
293
+
294
+ /** §8.6.2 `doc`-present-iff-not-`leave`: a leave carries null, a
295
+ * join/update carries an object. */
296
+ function validatePresenceFanoutDoc(kind: PresenceKind, doc: unknown): void {
297
+ if (kind === 'leave') {
298
+ if (doc !== null && doc !== undefined) {
299
+ malformed('presence leave must carry doc: null');
300
+ }
301
+ return;
302
+ }
303
+ requirePresenceDocObject(doc);
304
+ }
305
+
306
+ function requirePresenceDocObject(value: unknown): void {
307
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
308
+ malformed('presence.data.doc must be a JSON object');
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Parse a client → server presence publish/leave (§8.6.2). Used by the
314
+ * server to validate inbound `presence` control messages. A malformed
315
+ * message (missing/empty `scopeKey`, or a `doc` that is neither an object
316
+ * nor `null`) throws a `DecodeError` — a known event with wrong-shape data
317
+ * is never a tolerated variant (§8.1).
318
+ */
319
+ export function parseRealtimePresencePublish(
320
+ text: string,
321
+ ): RealtimePresencePublish {
322
+ let value: unknown;
323
+ try {
324
+ value = JSON.parse(text);
325
+ } catch {
326
+ malformed('not valid JSON');
327
+ }
328
+ const root = requireObject(value, 'control message');
329
+ if (root.event !== 'presence') {
330
+ malformed('not a presence control message');
331
+ }
332
+ const data = requireObject(root.data, 'presence.data');
333
+ const scopeKey = validatePresenceScopeKey(data.scopeKey);
334
+ const doc = data.doc;
335
+ if (doc !== null) requirePresenceDocObject(doc);
336
+ return {
337
+ event: 'presence',
338
+ data: { scopeKey, doc: doc as Record<string, unknown> | null },
339
+ };
340
+ }
package/src/render.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Canonical JSON debug rendering (SPEC.md §11).
3
+ *
4
+ * Non-contractual for the wire; contractual for golden vectors. Rendering
5
+ * rules: frames in wire order with END omitted, field order per the SPEC
6
+ * field tables, absent opt() fields omitted, binary fields as standard
7
+ * base64, enums by their spec names, i64 as JSON numbers, json-typed
8
+ * fields embedded as parsed JSON.
9
+ */
10
+ import type {
11
+ CommitChange,
12
+ PushOperation,
13
+ PushOperationResult,
14
+ RequestFrame,
15
+ ResponseFrame,
16
+ SyncMessage,
17
+ } from './message';
18
+ import { decodeMessage } from './message';
19
+ import type { RowColumn, RowValue } from './row-codec';
20
+ import type { RowsSegment } from './segment';
21
+ import { decodeRowsSegment, ROWS_SEGMENT_FORMAT_VERSION } from './segment';
22
+
23
+ export type JsonValue =
24
+ | null
25
+ | boolean
26
+ | number
27
+ | string
28
+ | JsonValue[]
29
+ | { [key: string]: JsonValue };
30
+
31
+ const BASE64_ALPHABET =
32
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
33
+
34
+ /** Standard base64 (§11.1 rule 4). Dependency- and runtime-agnostic. */
35
+ export function bytesToBase64(bytes: Uint8Array): string {
36
+ let out = '';
37
+ let i = 0;
38
+ for (; i + 2 < bytes.length; i += 3) {
39
+ const chunk =
40
+ ((bytes[i] ?? 0) << 16) |
41
+ ((bytes[i + 1] ?? 0) << 8) |
42
+ (bytes[i + 2] ?? 0);
43
+ out +=
44
+ BASE64_ALPHABET.charAt((chunk >> 18) & 63) +
45
+ BASE64_ALPHABET.charAt((chunk >> 12) & 63) +
46
+ BASE64_ALPHABET.charAt((chunk >> 6) & 63) +
47
+ BASE64_ALPHABET.charAt(chunk & 63);
48
+ }
49
+ const rest = bytes.length - i;
50
+ if (rest === 1) {
51
+ const chunk = (bytes[i] ?? 0) << 16;
52
+ out +=
53
+ BASE64_ALPHABET.charAt((chunk >> 18) & 63) +
54
+ BASE64_ALPHABET.charAt((chunk >> 12) & 63) +
55
+ '==';
56
+ } else if (rest === 2) {
57
+ const chunk = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8);
58
+ out +=
59
+ BASE64_ALPHABET.charAt((chunk >> 18) & 63) +
60
+ BASE64_ALPHABET.charAt((chunk >> 12) & 63) +
61
+ BASE64_ALPHABET.charAt((chunk >> 6) & 63) +
62
+ '=';
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function parseJson(raw: string): JsonValue {
68
+ return JSON.parse(raw) as JsonValue;
69
+ }
70
+
71
+ function renderScopeLists(
72
+ scopes: Readonly<Record<string, string[]>>,
73
+ ): JsonValue {
74
+ return Object.fromEntries(
75
+ Object.entries(scopes).map(([key, values]) => [key, [...values]]),
76
+ );
77
+ }
78
+
79
+ function renderStringMap(map: Readonly<Record<string, string>>): JsonValue {
80
+ return { ...map };
81
+ }
82
+
83
+ function renderOperation(operation: PushOperation): JsonValue {
84
+ return {
85
+ table: operation.table,
86
+ rowId: operation.rowId,
87
+ op: operation.op,
88
+ ...(operation.baseVersion !== undefined
89
+ ? { baseVersion: operation.baseVersion }
90
+ : {}),
91
+ ...(operation.payload !== undefined
92
+ ? { payload: bytesToBase64(operation.payload) }
93
+ : {}),
94
+ };
95
+ }
96
+
97
+ function renderResult(result: PushOperationResult): JsonValue {
98
+ if (result.status === 'applied') {
99
+ return { opIndex: result.opIndex, status: 'applied' };
100
+ }
101
+ if (result.status === 'conflict') {
102
+ return {
103
+ opIndex: result.opIndex,
104
+ status: 'conflict',
105
+ code: result.code,
106
+ message: result.message,
107
+ serverVersion: result.serverVersion,
108
+ serverRow: bytesToBase64(result.serverRow),
109
+ };
110
+ }
111
+ return {
112
+ opIndex: result.opIndex,
113
+ status: 'error',
114
+ code: result.code,
115
+ message: result.message,
116
+ retryable: result.retryable,
117
+ };
118
+ }
119
+
120
+ function renderChange(change: CommitChange): JsonValue {
121
+ return {
122
+ tableIndex: change.tableIndex,
123
+ rowId: change.rowId,
124
+ op: change.op,
125
+ ...(change.rowVersion !== undefined
126
+ ? { rowVersion: change.rowVersion }
127
+ : {}),
128
+ scopes: renderStringMap(change.scopes),
129
+ ...(change.row !== undefined ? { row: bytesToBase64(change.row) } : {}),
130
+ };
131
+ }
132
+
133
+ function renderFrame(frame: RequestFrame | ResponseFrame): JsonValue {
134
+ switch (frame.type) {
135
+ case 'REQ_HEADER':
136
+ return {
137
+ type: 'REQ_HEADER',
138
+ clientId: frame.clientId,
139
+ schemaVersion: frame.schemaVersion,
140
+ };
141
+ case 'PUSH_COMMIT':
142
+ return {
143
+ type: 'PUSH_COMMIT',
144
+ clientCommitId: frame.clientCommitId,
145
+ operations: frame.operations.map(renderOperation),
146
+ };
147
+ case 'PULL_HEADER':
148
+ return {
149
+ type: 'PULL_HEADER',
150
+ limitCommits: frame.limitCommits,
151
+ limitSnapshotRows: frame.limitSnapshotRows,
152
+ maxSnapshotPages: frame.maxSnapshotPages,
153
+ accept: frame.accept,
154
+ };
155
+ case 'SUBSCRIPTION':
156
+ return {
157
+ type: 'SUBSCRIPTION',
158
+ id: frame.id,
159
+ table: frame.table,
160
+ scopes: renderScopeLists(frame.scopes),
161
+ ...(frame.params !== undefined
162
+ ? { params: parseJson(frame.params) }
163
+ : {}),
164
+ cursor: frame.cursor,
165
+ ...(frame.bootstrapState !== undefined
166
+ ? { bootstrapState: parseJson(frame.bootstrapState) }
167
+ : {}),
168
+ };
169
+ case 'RESP_HEADER':
170
+ return {
171
+ type: 'RESP_HEADER',
172
+ ...(frame.requiredSchemaVersion !== undefined
173
+ ? { requiredSchemaVersion: frame.requiredSchemaVersion }
174
+ : {}),
175
+ ...(frame.latestSchemaVersion !== undefined
176
+ ? { latestSchemaVersion: frame.latestSchemaVersion }
177
+ : {}),
178
+ };
179
+ case 'LEASE':
180
+ return {
181
+ type: 'LEASE',
182
+ leaseId: frame.leaseId,
183
+ expiresAtMs: frame.expiresAtMs,
184
+ };
185
+ case 'PUSH_RESULT':
186
+ return {
187
+ type: 'PUSH_RESULT',
188
+ clientCommitId: frame.clientCommitId,
189
+ status: frame.status,
190
+ ...(frame.commitSeq !== undefined
191
+ ? { commitSeq: frame.commitSeq }
192
+ : {}),
193
+ results: frame.results.map(renderResult),
194
+ };
195
+ case 'SUB_START':
196
+ return {
197
+ type: 'SUB_START',
198
+ id: frame.id,
199
+ status: frame.status,
200
+ reasonCode: frame.reasonCode,
201
+ effectiveScopes: renderScopeLists(frame.effectiveScopes),
202
+ bootstrap: frame.bootstrap,
203
+ };
204
+ case 'COMMIT':
205
+ return {
206
+ type: 'COMMIT',
207
+ commitSeq: frame.commitSeq,
208
+ createdAtMs: frame.createdAtMs,
209
+ actorId: frame.actorId,
210
+ tables: [...frame.tables],
211
+ changes: frame.changes.map(renderChange),
212
+ };
213
+ case 'SEGMENT_REF':
214
+ return {
215
+ type: 'SEGMENT_REF',
216
+ segmentId: frame.segmentId,
217
+ mediaType: frame.mediaType,
218
+ table: frame.table,
219
+ byteLength: frame.byteLength,
220
+ rowCount: frame.rowCount,
221
+ asOfCommitSeq: frame.asOfCommitSeq,
222
+ scopeDigest: frame.scopeDigest,
223
+ ...(frame.rowCursor !== undefined
224
+ ? { rowCursor: frame.rowCursor }
225
+ : {}),
226
+ ...(frame.nextRowCursor !== undefined
227
+ ? { nextRowCursor: frame.nextRowCursor }
228
+ : {}),
229
+ ...(frame.url !== undefined ? { url: frame.url } : {}),
230
+ ...(frame.urlExpiresAtMs !== undefined
231
+ ? { urlExpiresAtMs: frame.urlExpiresAtMs }
232
+ : {}),
233
+ };
234
+ case 'SEGMENT_INLINE':
235
+ return {
236
+ type: 'SEGMENT_INLINE',
237
+ payload: bytesToBase64(frame.payload),
238
+ };
239
+ case 'SUB_END':
240
+ return {
241
+ type: 'SUB_END',
242
+ nextCursor: frame.nextCursor,
243
+ ...(frame.bootstrapState !== undefined
244
+ ? { bootstrapState: parseJson(frame.bootstrapState) }
245
+ : {}),
246
+ };
247
+ case 'ERROR':
248
+ return {
249
+ type: 'ERROR',
250
+ code: frame.code,
251
+ message: frame.message,
252
+ category: frame.category,
253
+ retryable: frame.retryable,
254
+ recommendedAction: frame.recommendedAction,
255
+ ...(frame.details !== undefined
256
+ ? { details: parseJson(frame.details) }
257
+ : {}),
258
+ };
259
+ case 'UNKNOWN':
260
+ return {
261
+ type: 'UNKNOWN',
262
+ frameType: frame.frameType,
263
+ payload: bytesToBase64(frame.payload),
264
+ };
265
+ }
266
+ }
267
+
268
+ export function renderMessageValue(message: SyncMessage): JsonValue {
269
+ return {
270
+ magic: 'SSP2',
271
+ wireVersion: message.wireVersion,
272
+ msgKind: message.msgKind,
273
+ frames: message.frames.map((frame: RequestFrame | ResponseFrame) =>
274
+ renderFrame(frame),
275
+ ),
276
+ };
277
+ }
278
+
279
+ /** `render(bytes) → json` for SSP2 messages (§11). */
280
+ export function renderMessage(bytes: Uint8Array): JsonValue {
281
+ return renderMessageValue(decodeMessage(bytes));
282
+ }
283
+
284
+ function renderRowValue(column: RowColumn, value: RowValue): JsonValue {
285
+ if (value === null) return null;
286
+ if (value instanceof Uint8Array) return bytesToBase64(value);
287
+ if (
288
+ (column.type === 'json' || column.type === 'blob_ref') &&
289
+ typeof value === 'string'
290
+ ) {
291
+ // §11: json and blob_ref (tag 7) render as embedded parsed JSON.
292
+ return parseJson(value);
293
+ }
294
+ return value;
295
+ }
296
+
297
+ export function renderRowsSegmentValue(segment: RowsSegment): JsonValue {
298
+ return {
299
+ magic: 'SSG2',
300
+ formatVersion: ROWS_SEGMENT_FORMAT_VERSION,
301
+ table: segment.table,
302
+ schemaVersion: segment.schemaVersion,
303
+ columns: segment.columns.map((column) => ({
304
+ name: column.name,
305
+ type: column.type,
306
+ nullable: column.nullable,
307
+ })),
308
+ blocks: segment.blocks.map((block) =>
309
+ block.map((row) => ({
310
+ serverVersion: row.serverVersion,
311
+ values: Object.fromEntries(
312
+ segment.columns.map((column, i) => [
313
+ column.name,
314
+ renderRowValue(column, row.values[i] ?? null),
315
+ ]),
316
+ ),
317
+ })),
318
+ ),
319
+ };
320
+ }
321
+
322
+ /** `render(bytes) → json` for standalone SSG2 rows segments (§11 rule 8). */
323
+ export function renderRowsSegment(bytes: Uint8Array): JsonValue {
324
+ return renderRowsSegmentValue(decodeRowsSegment(bytes));
325
+ }