@syncular/core 0.1.3 → 0.2.1

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,92 @@
1
+ /** §8.7 channel tag: a standalone SSP2 response message — a delta. */
2
+ export declare const REALTIME_TAG_DELTA = 0;
3
+ /** §8.7 channel tag: one chunk of the in-flight sync round's byte
4
+ * stream (request client→server, response server→client). */
5
+ export declare const REALTIME_TAG_ROUND = 1;
6
+ export interface RealtimeHelloEvent {
7
+ event: 'hello';
8
+ data: {
9
+ protocolVersion: number;
10
+ sessionId: string;
11
+ actorId: string;
12
+ clientId: string;
13
+ cursor: number;
14
+ latestCursor: number;
15
+ requiresSync: boolean;
16
+ timestamp: number;
17
+ };
18
+ }
19
+ export type WakeReason = 'delta-too-large' | 'catchup-required' | 'reset-required';
20
+ export interface RealtimeSyncEvent {
21
+ event: 'sync';
22
+ data: {
23
+ cursor: number;
24
+ requiresPull: true;
25
+ reason: WakeReason;
26
+ timestamp: number;
27
+ };
28
+ }
29
+ export interface RealtimeHeartbeatEvent {
30
+ event: 'heartbeat';
31
+ data: {
32
+ timestamp: number;
33
+ };
34
+ }
35
+ /** §8.6.2 presence fanout kind — a closed set of three. */
36
+ export type PresenceKind = 'join' | 'update' | 'leave';
37
+ /**
38
+ * Server → client presence fanout (§8.6.2): a scope-mate's presence for a
39
+ * key the receiver holds changed. `doc` is the peer's document for
40
+ * `join`/`update` and `null` for `leave`. An `error` variant carries a
41
+ * client-runtime `presence.*` code back to the publisher (size cap /
42
+ * forbidden, §8.6.2); it never fans out to peers.
43
+ */
44
+ export interface RealtimePresenceEvent {
45
+ event: 'presence';
46
+ data: {
47
+ scopeKey: string;
48
+ kind?: PresenceKind;
49
+ actorId?: string;
50
+ clientId?: string;
51
+ doc?: Record<string, unknown> | null;
52
+ error?: string;
53
+ timestamp?: number;
54
+ };
55
+ }
56
+ export type RealtimeServerEvent = RealtimeHelloEvent | RealtimeSyncEvent | RealtimeHeartbeatEvent | RealtimePresenceEvent;
57
+ /** Client → server delta acknowledgement (§8.2). */
58
+ export interface RealtimeAck {
59
+ type: 'ack';
60
+ cursor: number;
61
+ }
62
+ /** Client → server presence publish/leave (§8.6.2). `doc: null` = leave. */
63
+ export interface RealtimePresencePublish {
64
+ event: 'presence';
65
+ data: {
66
+ scopeKey: string;
67
+ doc: Record<string, unknown> | null;
68
+ };
69
+ }
70
+ export type ParsedRealtimeEvent = {
71
+ known: true;
72
+ event: RealtimeServerEvent;
73
+ } | {
74
+ known: false;
75
+ eventName: string;
76
+ };
77
+ /** Serialize a client→server presence publish/leave (§8.6.2). */
78
+ export declare function encodePresencePublish(scopeKey: string, doc: Record<string, unknown> | null): string;
79
+ /** Serialize a server→client presence fanout event (§8.6.2). */
80
+ export declare function encodePresenceFanout(scopeKey: string, kind: PresenceKind, actorId: string, clientId: string, doc: Record<string, unknown> | null, timestamp: number): string;
81
+ /** Serialize a server→client presence error directed at the publisher
82
+ * (§8.6.2 `presence.too_large` / `presence.forbidden`). */
83
+ export declare function encodePresenceError(scopeKey: string, error: string, timestamp: number): string;
84
+ export declare function parseRealtimeServerEvent(text: string): ParsedRealtimeEvent;
85
+ /**
86
+ * Parse a client → server presence publish/leave (§8.6.2). Used by the
87
+ * server to validate inbound `presence` control messages. A malformed
88
+ * message (missing/empty `scopeKey`, or a `doc` that is neither an object
89
+ * nor `null`) throws a `DecodeError` — a known event with wrong-shape data
90
+ * is never a tolerated variant (§8.1).
91
+ */
92
+ export declare function parseRealtimePresencePublish(text: string): RealtimePresencePublish;
@@ -0,0 +1,222 @@
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.js';
12
+ /** §8.7 channel tag: a standalone SSP2 response message — a delta. */
13
+ export const REALTIME_TAG_DELTA = 0x00;
14
+ /** §8.7 channel tag: one chunk of the in-flight sync round's byte
15
+ * stream (request client→server, response server→client). */
16
+ export const REALTIME_TAG_ROUND = 0x01;
17
+ const PRESENCE_KINDS = ['join', 'update', 'leave'];
18
+ /** Serialize a client→server presence publish/leave (§8.6.2). */
19
+ export function encodePresencePublish(scopeKey, doc) {
20
+ return JSON.stringify({ event: 'presence', data: { scopeKey, doc } });
21
+ }
22
+ /** Serialize a server→client presence fanout event (§8.6.2). */
23
+ export function encodePresenceFanout(scopeKey, kind, actorId, clientId, doc, timestamp) {
24
+ return JSON.stringify({
25
+ event: 'presence',
26
+ data: { scopeKey, kind, actorId, clientId, doc, timestamp },
27
+ });
28
+ }
29
+ /** Serialize a server→client presence error directed at the publisher
30
+ * (§8.6.2 `presence.too_large` / `presence.forbidden`). */
31
+ export function encodePresenceError(scopeKey, error, timestamp) {
32
+ return JSON.stringify({
33
+ event: 'presence',
34
+ data: { scopeKey, error, timestamp },
35
+ });
36
+ }
37
+ function malformed(what) {
38
+ throw new DecodeError('sync.invalid_request', `malformed realtime control message: ${what}`);
39
+ }
40
+ function requireObject(value, what) {
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
42
+ malformed(`${what} must be a JSON object`);
43
+ }
44
+ return value;
45
+ }
46
+ function requireNumber(value, what) {
47
+ // Realtime numeric fields are integers within the ±(2^53−1) i64 contract
48
+ // (SPEC.md §8.1); fractional or non-finite numbers are malformed.
49
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
50
+ malformed(`${what} must be an integer within the i64 safe range`);
51
+ }
52
+ return value;
53
+ }
54
+ function requireString(value, what) {
55
+ if (typeof value !== 'string')
56
+ malformed(`${what} must be a string`);
57
+ return value;
58
+ }
59
+ function requireBoolean(value, what) {
60
+ if (typeof value !== 'boolean')
61
+ malformed(`${what} must be a boolean`);
62
+ return value;
63
+ }
64
+ const WAKE_REASONS = [
65
+ 'delta-too-large',
66
+ 'catchup-required',
67
+ 'reset-required',
68
+ ];
69
+ export function parseRealtimeServerEvent(text) {
70
+ let value;
71
+ try {
72
+ value = JSON.parse(text);
73
+ }
74
+ catch {
75
+ malformed('not valid JSON');
76
+ }
77
+ const root = requireObject(value, 'control message');
78
+ const event = requireString(root.event, 'event');
79
+ if (event === 'hello') {
80
+ const data = requireObject(root.data, 'hello.data');
81
+ return {
82
+ known: true,
83
+ event: {
84
+ event: 'hello',
85
+ data: {
86
+ protocolVersion: requireNumber(data.protocolVersion, 'hello.data.protocolVersion'),
87
+ sessionId: requireString(data.sessionId, 'hello.data.sessionId'),
88
+ actorId: requireString(data.actorId, 'hello.data.actorId'),
89
+ clientId: requireString(data.clientId, 'hello.data.clientId'),
90
+ cursor: requireNumber(data.cursor, 'hello.data.cursor'),
91
+ latestCursor: requireNumber(data.latestCursor, 'hello.data.latestCursor'),
92
+ requiresSync: requireBoolean(data.requiresSync, 'hello.data.requiresSync'),
93
+ timestamp: requireNumber(data.timestamp, 'hello.data.timestamp'),
94
+ },
95
+ },
96
+ };
97
+ }
98
+ if (event === 'sync') {
99
+ const data = requireObject(root.data, 'sync.data');
100
+ const reason = requireString(data.reason, 'sync.data.reason');
101
+ if (!WAKE_REASONS.includes(reason)) {
102
+ malformed(`unknown wake reason ${JSON.stringify(reason)}`);
103
+ }
104
+ if (data.requiresPull !== true) {
105
+ malformed('sync.data.requiresPull must be true');
106
+ }
107
+ return {
108
+ known: true,
109
+ event: {
110
+ event: 'sync',
111
+ data: {
112
+ cursor: requireNumber(data.cursor, 'sync.data.cursor'),
113
+ requiresPull: true,
114
+ reason: reason,
115
+ timestamp: requireNumber(data.timestamp, 'sync.data.timestamp'),
116
+ },
117
+ },
118
+ };
119
+ }
120
+ if (event === 'heartbeat') {
121
+ const data = requireObject(root.data, 'heartbeat.data');
122
+ return {
123
+ known: true,
124
+ event: {
125
+ event: 'heartbeat',
126
+ data: {
127
+ timestamp: requireNumber(data.timestamp, 'heartbeat.data.timestamp'),
128
+ },
129
+ },
130
+ };
131
+ }
132
+ if (event === 'presence') {
133
+ // §8.6.2: validate the presence shape but carry `data` through
134
+ // verbatim so re-encoding is byte-identical (host-defined `doc`). A
135
+ // `presence` message is recognized in both directions: a fanout (has
136
+ // `kind`), a publisher-directed error (has `error`), or a client→server
137
+ // publish (neither — just `scopeKey`/`doc`, §8.6.2).
138
+ const data = requireObject(root.data, 'presence.data');
139
+ validatePresenceScopeKey(data.scopeKey);
140
+ if ('error' in data) {
141
+ // The publisher-directed error variant (`presence.too_large` /
142
+ // `presence.forbidden`, §8.6.2) — a string code, no `kind`/`doc`.
143
+ requireString(data.error, 'presence.data.error');
144
+ }
145
+ else if ('kind' in data) {
146
+ validatePresenceKind(data.kind);
147
+ requireString(data.actorId, 'presence.data.actorId');
148
+ requireString(data.clientId, 'presence.data.clientId');
149
+ validatePresenceFanoutDoc(data.kind, data.doc);
150
+ }
151
+ else {
152
+ // Client→server publish/leave: scopeKey + doc (object or null).
153
+ if (data.doc !== null)
154
+ requirePresenceDocObject(data.doc);
155
+ }
156
+ if (data.timestamp !== undefined) {
157
+ requireNumber(data.timestamp, 'presence.data.timestamp');
158
+ }
159
+ return {
160
+ known: true,
161
+ event: { event: 'presence', data: data },
162
+ };
163
+ }
164
+ return { known: false, eventName: event };
165
+ }
166
+ function validatePresenceScopeKey(value) {
167
+ const key = requireString(value, 'presence.data.scopeKey');
168
+ if (key.length === 0)
169
+ malformed('presence.data.scopeKey must be non-empty');
170
+ return key;
171
+ }
172
+ function validatePresenceKind(value) {
173
+ const kind = requireString(value, 'presence.data.kind');
174
+ if (!PRESENCE_KINDS.includes(kind)) {
175
+ malformed(`unknown presence kind ${JSON.stringify(kind)}`);
176
+ }
177
+ }
178
+ /** §8.6.2 `doc`-present-iff-not-`leave`: a leave carries null, a
179
+ * join/update carries an object. */
180
+ function validatePresenceFanoutDoc(kind, doc) {
181
+ if (kind === 'leave') {
182
+ if (doc !== null && doc !== undefined) {
183
+ malformed('presence leave must carry doc: null');
184
+ }
185
+ return;
186
+ }
187
+ requirePresenceDocObject(doc);
188
+ }
189
+ function requirePresenceDocObject(value) {
190
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
191
+ malformed('presence.data.doc must be a JSON object');
192
+ }
193
+ }
194
+ /**
195
+ * Parse a client → server presence publish/leave (§8.6.2). Used by the
196
+ * server to validate inbound `presence` control messages. A malformed
197
+ * message (missing/empty `scopeKey`, or a `doc` that is neither an object
198
+ * nor `null`) throws a `DecodeError` — a known event with wrong-shape data
199
+ * is never a tolerated variant (§8.1).
200
+ */
201
+ export function parseRealtimePresencePublish(text) {
202
+ let value;
203
+ try {
204
+ value = JSON.parse(text);
205
+ }
206
+ catch {
207
+ malformed('not valid JSON');
208
+ }
209
+ const root = requireObject(value, 'control message');
210
+ if (root.event !== 'presence') {
211
+ malformed('not a presence control message');
212
+ }
213
+ const data = requireObject(root.data, 'presence.data');
214
+ const scopeKey = validatePresenceScopeKey(data.scopeKey);
215
+ const doc = data.doc;
216
+ if (doc !== null)
217
+ requirePresenceDocObject(doc);
218
+ return {
219
+ event: 'presence',
220
+ data: { scopeKey, doc: doc },
221
+ };
222
+ }
@@ -0,0 +1,22 @@
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 { SyncMessage } from './message.js';
11
+ import type { RowsSegment } from './segment.js';
12
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
13
+ [key: string]: JsonValue;
14
+ };
15
+ /** Standard base64 (§11.1 rule 4). Dependency- and runtime-agnostic. */
16
+ export declare function bytesToBase64(bytes: Uint8Array): string;
17
+ export declare function renderMessageValue(message: SyncMessage): JsonValue;
18
+ /** `render(bytes) → json` for SSP2 messages (§11). */
19
+ export declare function renderMessage(bytes: Uint8Array): JsonValue;
20
+ export declare function renderRowsSegmentValue(segment: RowsSegment): JsonValue;
21
+ /** `render(bytes) → json` for standalone SSG2 rows segments (§11 rule 8). */
22
+ export declare function renderRowsSegment(bytes: Uint8Array): JsonValue;
package/dist/render.js ADDED
@@ -0,0 +1,273 @@
1
+ import { decodeMessage } from './message.js';
2
+ import { decodeRowsSegment, ROWS_SEGMENT_FORMAT_VERSION } from './segment.js';
3
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
4
+ /** Standard base64 (§11.1 rule 4). Dependency- and runtime-agnostic. */
5
+ export function bytesToBase64(bytes) {
6
+ let out = '';
7
+ let i = 0;
8
+ for (; i + 2 < bytes.length; i += 3) {
9
+ const chunk = ((bytes[i] ?? 0) << 16) |
10
+ ((bytes[i + 1] ?? 0) << 8) |
11
+ (bytes[i + 2] ?? 0);
12
+ out +=
13
+ BASE64_ALPHABET.charAt((chunk >> 18) & 63) +
14
+ BASE64_ALPHABET.charAt((chunk >> 12) & 63) +
15
+ BASE64_ALPHABET.charAt((chunk >> 6) & 63) +
16
+ BASE64_ALPHABET.charAt(chunk & 63);
17
+ }
18
+ const rest = bytes.length - i;
19
+ if (rest === 1) {
20
+ const chunk = (bytes[i] ?? 0) << 16;
21
+ out +=
22
+ BASE64_ALPHABET.charAt((chunk >> 18) & 63) +
23
+ BASE64_ALPHABET.charAt((chunk >> 12) & 63) +
24
+ '==';
25
+ }
26
+ else if (rest === 2) {
27
+ const chunk = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8);
28
+ out +=
29
+ BASE64_ALPHABET.charAt((chunk >> 18) & 63) +
30
+ BASE64_ALPHABET.charAt((chunk >> 12) & 63) +
31
+ BASE64_ALPHABET.charAt((chunk >> 6) & 63) +
32
+ '=';
33
+ }
34
+ return out;
35
+ }
36
+ function parseJson(raw) {
37
+ return JSON.parse(raw);
38
+ }
39
+ function renderScopeLists(scopes) {
40
+ return Object.fromEntries(Object.entries(scopes).map(([key, values]) => [key, [...values]]));
41
+ }
42
+ function renderStringMap(map) {
43
+ return { ...map };
44
+ }
45
+ function renderOperation(operation) {
46
+ return {
47
+ table: operation.table,
48
+ rowId: operation.rowId,
49
+ op: operation.op,
50
+ ...(operation.baseVersion !== undefined
51
+ ? { baseVersion: operation.baseVersion }
52
+ : {}),
53
+ ...(operation.payload !== undefined
54
+ ? { payload: bytesToBase64(operation.payload) }
55
+ : {}),
56
+ };
57
+ }
58
+ function renderResult(result) {
59
+ if (result.status === 'applied') {
60
+ return { opIndex: result.opIndex, status: 'applied' };
61
+ }
62
+ if (result.status === 'conflict') {
63
+ return {
64
+ opIndex: result.opIndex,
65
+ status: 'conflict',
66
+ code: result.code,
67
+ message: result.message,
68
+ serverVersion: result.serverVersion,
69
+ serverRow: bytesToBase64(result.serverRow),
70
+ };
71
+ }
72
+ return {
73
+ opIndex: result.opIndex,
74
+ status: 'error',
75
+ code: result.code,
76
+ message: result.message,
77
+ retryable: result.retryable,
78
+ };
79
+ }
80
+ function renderChange(change) {
81
+ return {
82
+ tableIndex: change.tableIndex,
83
+ rowId: change.rowId,
84
+ op: change.op,
85
+ ...(change.rowVersion !== undefined
86
+ ? { rowVersion: change.rowVersion }
87
+ : {}),
88
+ scopes: renderStringMap(change.scopes),
89
+ ...(change.row !== undefined ? { row: bytesToBase64(change.row) } : {}),
90
+ };
91
+ }
92
+ function renderFrame(frame) {
93
+ switch (frame.type) {
94
+ case 'REQ_HEADER':
95
+ return {
96
+ type: 'REQ_HEADER',
97
+ clientId: frame.clientId,
98
+ schemaVersion: frame.schemaVersion,
99
+ };
100
+ case 'PUSH_COMMIT':
101
+ return {
102
+ type: 'PUSH_COMMIT',
103
+ clientCommitId: frame.clientCommitId,
104
+ operations: frame.operations.map(renderOperation),
105
+ };
106
+ case 'PULL_HEADER':
107
+ return {
108
+ type: 'PULL_HEADER',
109
+ limitCommits: frame.limitCommits,
110
+ limitSnapshotRows: frame.limitSnapshotRows,
111
+ maxSnapshotPages: frame.maxSnapshotPages,
112
+ accept: frame.accept,
113
+ };
114
+ case 'SUBSCRIPTION':
115
+ return {
116
+ type: 'SUBSCRIPTION',
117
+ id: frame.id,
118
+ table: frame.table,
119
+ scopes: renderScopeLists(frame.scopes),
120
+ ...(frame.params !== undefined
121
+ ? { params: parseJson(frame.params) }
122
+ : {}),
123
+ cursor: frame.cursor,
124
+ ...(frame.bootstrapState !== undefined
125
+ ? { bootstrapState: parseJson(frame.bootstrapState) }
126
+ : {}),
127
+ };
128
+ case 'RESP_HEADER':
129
+ return {
130
+ type: 'RESP_HEADER',
131
+ ...(frame.requiredSchemaVersion !== undefined
132
+ ? { requiredSchemaVersion: frame.requiredSchemaVersion }
133
+ : {}),
134
+ ...(frame.latestSchemaVersion !== undefined
135
+ ? { latestSchemaVersion: frame.latestSchemaVersion }
136
+ : {}),
137
+ };
138
+ case 'LEASE':
139
+ return {
140
+ type: 'LEASE',
141
+ leaseId: frame.leaseId,
142
+ expiresAtMs: frame.expiresAtMs,
143
+ };
144
+ case 'PUSH_RESULT':
145
+ return {
146
+ type: 'PUSH_RESULT',
147
+ clientCommitId: frame.clientCommitId,
148
+ status: frame.status,
149
+ ...(frame.commitSeq !== undefined
150
+ ? { commitSeq: frame.commitSeq }
151
+ : {}),
152
+ results: frame.results.map(renderResult),
153
+ };
154
+ case 'SUB_START':
155
+ return {
156
+ type: 'SUB_START',
157
+ id: frame.id,
158
+ status: frame.status,
159
+ reasonCode: frame.reasonCode,
160
+ effectiveScopes: renderScopeLists(frame.effectiveScopes),
161
+ bootstrap: frame.bootstrap,
162
+ };
163
+ case 'COMMIT':
164
+ return {
165
+ type: 'COMMIT',
166
+ commitSeq: frame.commitSeq,
167
+ createdAtMs: frame.createdAtMs,
168
+ actorId: frame.actorId,
169
+ tables: [...frame.tables],
170
+ changes: frame.changes.map(renderChange),
171
+ };
172
+ case 'SEGMENT_REF':
173
+ return {
174
+ type: 'SEGMENT_REF',
175
+ segmentId: frame.segmentId,
176
+ mediaType: frame.mediaType,
177
+ table: frame.table,
178
+ byteLength: frame.byteLength,
179
+ rowCount: frame.rowCount,
180
+ asOfCommitSeq: frame.asOfCommitSeq,
181
+ scopeDigest: frame.scopeDigest,
182
+ ...(frame.rowCursor !== undefined
183
+ ? { rowCursor: frame.rowCursor }
184
+ : {}),
185
+ ...(frame.nextRowCursor !== undefined
186
+ ? { nextRowCursor: frame.nextRowCursor }
187
+ : {}),
188
+ ...(frame.url !== undefined ? { url: frame.url } : {}),
189
+ ...(frame.urlExpiresAtMs !== undefined
190
+ ? { urlExpiresAtMs: frame.urlExpiresAtMs }
191
+ : {}),
192
+ };
193
+ case 'SEGMENT_INLINE':
194
+ return {
195
+ type: 'SEGMENT_INLINE',
196
+ payload: bytesToBase64(frame.payload),
197
+ };
198
+ case 'SUB_END':
199
+ return {
200
+ type: 'SUB_END',
201
+ nextCursor: frame.nextCursor,
202
+ ...(frame.bootstrapState !== undefined
203
+ ? { bootstrapState: parseJson(frame.bootstrapState) }
204
+ : {}),
205
+ };
206
+ case 'ERROR':
207
+ return {
208
+ type: 'ERROR',
209
+ code: frame.code,
210
+ message: frame.message,
211
+ category: frame.category,
212
+ retryable: frame.retryable,
213
+ recommendedAction: frame.recommendedAction,
214
+ ...(frame.details !== undefined
215
+ ? { details: parseJson(frame.details) }
216
+ : {}),
217
+ };
218
+ case 'UNKNOWN':
219
+ return {
220
+ type: 'UNKNOWN',
221
+ frameType: frame.frameType,
222
+ payload: bytesToBase64(frame.payload),
223
+ };
224
+ }
225
+ }
226
+ export function renderMessageValue(message) {
227
+ return {
228
+ magic: 'SSP2',
229
+ wireVersion: message.wireVersion,
230
+ msgKind: message.msgKind,
231
+ frames: message.frames.map((frame) => renderFrame(frame)),
232
+ };
233
+ }
234
+ /** `render(bytes) → json` for SSP2 messages (§11). */
235
+ export function renderMessage(bytes) {
236
+ return renderMessageValue(decodeMessage(bytes));
237
+ }
238
+ function renderRowValue(column, value) {
239
+ if (value === null)
240
+ return null;
241
+ if (value instanceof Uint8Array)
242
+ return bytesToBase64(value);
243
+ if ((column.type === 'json' || column.type === 'blob_ref') &&
244
+ typeof value === 'string') {
245
+ // §11: json and blob_ref (tag 7) render as embedded parsed JSON.
246
+ return parseJson(value);
247
+ }
248
+ return value;
249
+ }
250
+ export function renderRowsSegmentValue(segment) {
251
+ return {
252
+ magic: 'SSG2',
253
+ formatVersion: ROWS_SEGMENT_FORMAT_VERSION,
254
+ table: segment.table,
255
+ schemaVersion: segment.schemaVersion,
256
+ columns: segment.columns.map((column) => ({
257
+ name: column.name,
258
+ type: column.type,
259
+ nullable: column.nullable,
260
+ })),
261
+ blocks: segment.blocks.map((block) => block.map((row) => ({
262
+ serverVersion: row.serverVersion,
263
+ values: Object.fromEntries(segment.columns.map((column, i) => [
264
+ column.name,
265
+ renderRowValue(column, row.values[i] ?? null),
266
+ ])),
267
+ }))),
268
+ };
269
+ }
270
+ /** `render(bytes) → json` for standalone SSG2 rows segments (§11 rule 8). */
271
+ export function renderRowsSegment(bytes) {
272
+ return renderRowsSegmentValue(decodeRowsSegment(bytes));
273
+ }
@@ -0,0 +1,39 @@
1
+ import { ByteReader, ByteWriter } from './bytes.js';
2
+ export type ColumnType = 'string' | 'integer' | 'float' | 'boolean' | 'json' | 'bytes' | 'blob_ref' | 'crdt';
3
+ export interface RowColumn {
4
+ readonly name: string;
5
+ readonly type: ColumnType;
6
+ readonly nullable: boolean;
7
+ /**
8
+ * For a `crdt` column (§2.4 tag 8, §5.10.1): the named merger the server
9
+ * selects (this rung defines exactly `'yjs-doc'`). Schema-IR metadata
10
+ * ONLY — never on the wire (the SSG2 column table carries name/type/
11
+ * nullable, and `crdt` shares the `bytes` tag). Ignored for other types.
12
+ */
13
+ readonly crdtType?: string;
14
+ /**
15
+ * §5.11: this column is encrypted end-to-end. When set, `type` is `bytes`
16
+ * (the wire/stored type — the ciphertext envelope rides the bytes machinery)
17
+ * and `declaredType` is the pre-flip app type. Both are schema-IR metadata,
18
+ * never on the wire (like `crdtType`). The row codec ignores these — it
19
+ * only ever sees a `bytes` value; encryption/decryption is the client
20
+ * encode/apply seam (§5.11).
21
+ */
22
+ readonly encrypted?: boolean;
23
+ /** §5.11: the app-side type of an encrypted column (its type before the
24
+ * wire flip to `bytes`). Present iff `encrypted` is set. */
25
+ readonly declaredType?: ColumnType;
26
+ }
27
+ /**
28
+ * `integer` is `i64` within the ±(2^53−1) contract; `json` is the raw JSON
29
+ * document string, preserved byte-for-byte on round-trip.
30
+ */
31
+ export type RowValue = string | number | boolean | Uint8Array | null;
32
+ export declare function columnTypeTag(type: ColumnType): number;
33
+ export declare function columnTypeFromTag(tag: number): ColumnType;
34
+ export declare function writeRow(writer: ByteWriter, columns: readonly RowColumn[], values: readonly RowValue[]): void;
35
+ export declare function readRow(reader: ByteReader, columns: readonly RowColumn[]): RowValue[];
36
+ /** Encode one standalone row (e.g. a push payload or conflict serverRow). */
37
+ export declare function encodeRow(columns: readonly RowColumn[], values: readonly RowValue[]): Uint8Array;
38
+ /** Decode one standalone row; the bytes must contain exactly one row. */
39
+ export declare function decodeRow(columns: readonly RowColumn[], bytes: Uint8Array): RowValue[];