@syncular/core 0.20.1 → 0.21.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.
@@ -1,8 +1,10 @@
1
1
  /** Protocol constants (SPEC.md §1.2, §9 are normative). */
2
2
  /** Latest SSP2 envelope wire version emitted by current clients. */
3
- export declare const PROTOCOL_WIRE_VERSION = 2;
4
- /** Oldest SSP2 envelope wire version accepted during the epoch rollout. */
5
- export declare const MINIMUM_PROTOCOL_WIRE_VERSION = 1;
3
+ export declare const PROTOCOL_WIRE_VERSION = 3;
4
+ /** Oldest SSP2 envelope wire version accepted. Wire version 3 changed the
5
+ * push payload to the sparse row codec (§2.4) and the conflict record shape
6
+ * (§6.3); versions 1 and 2 are rejected as unknown (§9). */
7
+ export declare const MINIMUM_PROTOCOL_WIRE_VERSION = 3;
6
8
  /** Wire versions implemented by the reference codec, oldest first. */
7
9
  export declare const SUPPORTED_PROTOCOL_WIRE_VERSIONS: readonly number[];
8
10
  /** Whether the reference codec implements this exact wire version. */
package/dist/constants.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /** Protocol constants (SPEC.md §1.2, §9 are normative). */
2
2
  /** Latest SSP2 envelope wire version emitted by current clients. */
3
- export const PROTOCOL_WIRE_VERSION = 2;
4
- /** Oldest SSP2 envelope wire version accepted during the epoch rollout. */
5
- export const MINIMUM_PROTOCOL_WIRE_VERSION = 1;
3
+ export const PROTOCOL_WIRE_VERSION = 3;
4
+ /** Oldest SSP2 envelope wire version accepted. Wire version 3 changed the
5
+ * push payload to the sparse row codec (§2.4) and the conflict record shape
6
+ * (§6.3); versions 1 and 2 are rejected as unknown (§9). */
7
+ export const MINIMUM_PROTOCOL_WIRE_VERSION = 3;
6
8
  /** Wire versions implemented by the reference codec, oldest first. */
7
9
  export const SUPPORTED_PROTOCOL_WIRE_VERSIONS = Object.freeze(Array.from({
8
10
  length: PROTOCOL_WIRE_VERSION - MINIMUM_PROTOCOL_WIRE_VERSION + 1,
package/dist/crypto.d.ts CHANGED
@@ -19,6 +19,18 @@ export declare class DecryptError extends Error {
19
19
  readonly code = "client.decrypt_failed";
20
20
  readonly retryable = false;
21
21
  }
22
+ /**
23
+ * Client-local encrypt failure (SPEC.md §5.11, §10.3). Never on the wire.
24
+ * Raised at the encode-at-send seam when the key id cannot be resolved
25
+ * from the present columns or the stored local row, or the selected key
26
+ * is unknown. Not retryable unmodified. Distinct from {@link DecryptError},
27
+ * which is apply-seam only.
28
+ */
29
+ export declare class EncryptError extends Error {
30
+ readonly name = "EncryptError";
31
+ readonly code = "client.encrypt_failed";
32
+ readonly retryable = false;
33
+ }
22
34
  /**
23
35
  * Injectable nonce source (SPEC.md §5.11 nonce discipline). Production uses
24
36
  * {@link secureRandomNonce}; crypto golden vectors inject a fixed nonce. A
package/dist/crypto.js CHANGED
@@ -30,6 +30,18 @@ export class DecryptError extends Error {
30
30
  code = 'client.decrypt_failed';
31
31
  retryable = false;
32
32
  }
33
+ /**
34
+ * Client-local encrypt failure (SPEC.md §5.11, §10.3). Never on the wire.
35
+ * Raised at the encode-at-send seam when the key id cannot be resolved
36
+ * from the present columns or the stored local row, or the selected key
37
+ * is unknown. Not retryable unmodified. Distinct from {@link DecryptError},
38
+ * which is apply-seam only.
39
+ */
40
+ export class EncryptError extends Error {
41
+ name = 'EncryptError';
42
+ code = 'client.encrypt_failed';
43
+ retryable = false;
44
+ }
33
45
  export function secureRandomNonce() {
34
46
  const nonce = new Uint8Array(NONCE_LENGTH);
35
47
  crypto.getRandomValues(nonce);
package/dist/message.d.ts CHANGED
@@ -71,6 +71,13 @@ export type PushOperationResult = {
71
71
  message: string;
72
72
  serverVersion: number;
73
73
  serverRow: Uint8Array;
74
+ /**
75
+ * §6.3 (wire version 3): presence-bitmap layout over the row's columns,
76
+ * marking the present columns whose `column_version` exceeded the
77
+ * operation's `baseVersion`. A `baseVersion = 0` lost-insert conflict
78
+ * marks every present non-crdt column.
79
+ */
80
+ conflictColumns: Uint8Array;
74
81
  } | {
75
82
  opIndex: number;
76
83
  status: 'error';
package/dist/message.js CHANGED
@@ -185,6 +185,7 @@ function decodePushResult(r) {
185
185
  message: r.str(),
186
186
  serverVersion: r.i64(),
187
187
  serverRow: r.bytes(),
188
+ conflictColumns: r.bytes(),
188
189
  });
189
190
  }
190
191
  else if (recordStatus === 3) {
@@ -709,6 +710,7 @@ function encodeFrame(frame, wireVersion) {
709
710
  w.str(result.message);
710
711
  w.i64(result.serverVersion);
711
712
  w.bytes(result.serverRow);
713
+ w.bytes(result.conflictColumns);
712
714
  }
713
715
  else {
714
716
  w.u8(3);
package/dist/render.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * fields embedded as parsed JSON.
9
9
  */
10
10
  import type { SyncMessage } from './message.js';
11
+ import type { RowColumn, SparseRowValue } from './row-codec.js';
11
12
  import type { RowsSegment } from './segment.js';
12
13
  export type JsonValue = null | boolean | number | string | JsonValue[] | {
13
14
  [key: string]: JsonValue;
@@ -20,3 +21,9 @@ export declare function renderMessage(bytes: Uint8Array): JsonValue;
20
21
  export declare function renderRowsSegmentValue(segment: RowsSegment): JsonValue;
21
22
  /** `render(bytes) → json` for standalone SSG2 rows segments (§11 rule 8). */
22
23
  export declare function renderRowsSegment(bytes: Uint8Array): JsonValue;
24
+ /**
25
+ * Render a decoded sparse row (RFC §6.1, §11 rule 11): the column table plus
26
+ * `values` keyed by column name, carrying only the present columns, so an
27
+ * absent column has no key and a present NULL renders as JSON `null`.
28
+ */
29
+ export declare function renderSparseRowValue(columns: readonly RowColumn[], values: readonly SparseRowValue[]): JsonValue;
package/dist/render.js CHANGED
@@ -67,6 +67,7 @@ function renderResult(result) {
67
67
  message: result.message,
68
68
  serverVersion: result.serverVersion,
69
69
  serverRow: bytesToBase64(result.serverRow),
70
+ conflictColumns: bytesToBase64(result.conflictColumns),
70
71
  };
71
72
  }
72
73
  return {
@@ -274,17 +275,20 @@ function renderRowValue(column, value) {
274
275
  }
275
276
  return value;
276
277
  }
278
+ function renderColumnTable(columns) {
279
+ return columns.map((column) => ({
280
+ name: column.name,
281
+ type: column.type,
282
+ nullable: column.nullable,
283
+ }));
284
+ }
277
285
  export function renderRowsSegmentValue(segment) {
278
286
  return {
279
287
  magic: 'SSG2',
280
288
  formatVersion: ROWS_SEGMENT_FORMAT_VERSION,
281
289
  table: segment.table,
282
290
  schemaVersion: segment.schemaVersion,
283
- columns: segment.columns.map((column) => ({
284
- name: column.name,
285
- type: column.type,
286
- nullable: column.nullable,
287
- })),
291
+ columns: renderColumnTable(segment.columns),
288
292
  blocks: segment.blocks.map((block) => block.map((row) => ({
289
293
  serverVersion: row.serverVersion,
290
294
  values: Object.fromEntries(segment.columns.map((column, i) => [
@@ -298,3 +302,22 @@ export function renderRowsSegmentValue(segment) {
298
302
  export function renderRowsSegment(bytes) {
299
303
  return renderRowsSegmentValue(decodeRowsSegment(bytes));
300
304
  }
305
+ /**
306
+ * Render a decoded sparse row (RFC §6.1, §11 rule 11): the column table plus
307
+ * `values` keyed by column name, carrying only the present columns, so an
308
+ * absent column has no key and a present NULL renders as JSON `null`.
309
+ */
310
+ export function renderSparseRowValue(columns, values) {
311
+ const present = [];
312
+ for (let i = 0; i < columns.length; i++) {
313
+ const column = columns[i];
314
+ const value = values[i];
315
+ if (column === undefined || value === undefined)
316
+ continue;
317
+ present.push([column.name, renderRowValue(column, value)]);
318
+ }
319
+ return {
320
+ columns: renderColumnTable(columns),
321
+ values: Object.fromEntries(present),
322
+ };
323
+ }
@@ -31,6 +31,27 @@ export interface RowColumn {
31
31
  export type RowValue = string | number | boolean | Uint8Array | null;
32
32
  export declare function columnTypeTag(type: ColumnType): number;
33
33
  export declare function columnTypeFromTag(tag: number): ColumnType;
34
+ /**
35
+ * A sparse-row slot (RFC §6.1): `undefined` when the column is absent from
36
+ * the payload (an apply leaves the stored value untouched), `null` when the
37
+ * column is present and NULL.
38
+ */
39
+ export type SparseRowValue = RowValue | undefined;
40
+ /**
41
+ * Encode one standalone sparse row (RFC §6.1, SPEC.md §2.4).
42
+ *
43
+ * `primaryKeyIndex` is the row's primary-key column; a payload without it is
44
+ * encoder misuse and throws, like a NULL in a non-nullable column.
45
+ */
46
+ export declare function encodeSparseRow(columns: readonly RowColumn[], primaryKeyIndex: number, values: readonly SparseRowValue[]): Uint8Array;
47
+ /**
48
+ * Decode one standalone sparse row; the bytes must contain exactly one row.
49
+ *
50
+ * Decode checks, in order (each a decode error, RFC §6.1): a set presence
51
+ * padding bit, an absent primary-key column, a set null-bitmap padding bit
52
+ * (a null for an absent column), a null bit for a non-nullable column.
53
+ */
54
+ export declare function decodeSparseRow(columns: readonly RowColumn[], primaryKeyIndex: number, bytes: Uint8Array): SparseRowValue[];
34
55
  export declare function writeRow(writer: ByteWriter, columns: readonly RowColumn[], values: readonly RowValue[]): void;
35
56
  export declare function readRow(reader: ByteReader, columns: readonly RowColumn[]): RowValue[];
36
57
  /** Encode one standalone row (e.g. a push payload or conflict serverRow). */
package/dist/row-codec.js CHANGED
@@ -4,8 +4,13 @@
4
4
  * A row is a null bitmap of ceil(columnCount / 8) bytes (bit i set =
5
5
  * column i is NULL; LSB-first within each byte, byte i/8), followed by the
6
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.
7
+ * Used for `COMMIT` change payloads, rows-segment row data, and conflict
8
+ * `serverRow` values; push operation payloads use the sparse row codec
9
+ * (`encodeSparseRow` / `decodeSparseRow`) instead.
10
+ *
11
+ * A sparse row (RFC §6.1) is the column subset a push operation writes: a
12
+ * presence bitmap over all columns, a null bitmap over the present columns,
13
+ * then the non-null present values. Absent columns keep their stored value.
9
14
  */
10
15
  import { parseBlobRef } from './blob-ref.js';
11
16
  import { ByteReader, ByteWriter } from './bytes.js';
@@ -109,6 +114,120 @@ function readValue(reader, column) {
109
114
  return reader.bytes();
110
115
  }
111
116
  }
117
+ /** One bit of a bitmap: LSB-first within byte `index / 8`. */
118
+ function bitmapBit(bitmap, index) {
119
+ return (((bitmap[index >> 3] ?? 0) >> (index & 7)) & 1) !== 0;
120
+ }
121
+ /**
122
+ * Encode one standalone sparse row (RFC §6.1, SPEC.md §2.4).
123
+ *
124
+ * `primaryKeyIndex` is the row's primary-key column; a payload without it is
125
+ * encoder misuse and throws, like a NULL in a non-nullable column.
126
+ */
127
+ export function encodeSparseRow(columns, primaryKeyIndex, values) {
128
+ if (values.length !== columns.length) {
129
+ throw new Error(`row value count ${values.length} does not match column count ${columns.length}`);
130
+ }
131
+ const presence = new Uint8Array(Math.ceil(columns.length / 8));
132
+ let presentCount = 0;
133
+ for (let i = 0; i < columns.length; i++) {
134
+ if (values[i] === undefined)
135
+ continue;
136
+ presence[i >> 3] = (presence[i >> 3] ?? 0) | (1 << (i & 7));
137
+ presentCount++;
138
+ }
139
+ const primaryKey = columns[primaryKeyIndex];
140
+ if (primaryKey === undefined) {
141
+ throw new Error(`primary-key column index ${primaryKeyIndex} is outside the column table`);
142
+ }
143
+ if (!bitmapBit(presence, primaryKeyIndex)) {
144
+ throw new Error(`sparse row is missing the primary-key column ${primaryKey.name}`);
145
+ }
146
+ const nulls = new Uint8Array(Math.ceil(presentCount / 8));
147
+ let present = 0;
148
+ for (let i = 0; i < columns.length; i++) {
149
+ const column = columns[i];
150
+ const value = values[i];
151
+ if (column === undefined || value === undefined)
152
+ continue;
153
+ if (value === null) {
154
+ if (!column.nullable) {
155
+ throw new Error(`column ${column.name} is not nullable`);
156
+ }
157
+ nulls[present >> 3] = (nulls[present >> 3] ?? 0) | (1 << (present & 7));
158
+ }
159
+ present++;
160
+ }
161
+ const writer = new ByteWriter();
162
+ writer.raw(presence);
163
+ writer.raw(nulls);
164
+ for (let i = 0; i < columns.length; i++) {
165
+ const column = columns[i];
166
+ const value = values[i];
167
+ if (column === undefined || value === undefined || value === null) {
168
+ continue;
169
+ }
170
+ writeValue(writer, column, value);
171
+ }
172
+ return writer.finish();
173
+ }
174
+ /**
175
+ * Decode one standalone sparse row; the bytes must contain exactly one row.
176
+ *
177
+ * Decode checks, in order (each a decode error, RFC §6.1): a set presence
178
+ * padding bit, an absent primary-key column, a set null-bitmap padding bit
179
+ * (a null for an absent column), a null bit for a non-nullable column.
180
+ */
181
+ export function decodeSparseRow(columns, primaryKeyIndex, bytes) {
182
+ const reader = new ByteReader(bytes);
183
+ const presenceLength = Math.ceil(columns.length / 8);
184
+ const presence = reader.raw(presenceLength);
185
+ for (let bit = columns.length; bit < presenceLength * 8; bit++) {
186
+ if (bitmapBit(presence, bit)) {
187
+ throw new DecodeError('sync.invalid_request', 'sparse row presence bitmap has a set padding bit (non-canonical encoding)');
188
+ }
189
+ }
190
+ const primaryKey = columns[primaryKeyIndex];
191
+ if (primaryKey === undefined) {
192
+ throw new DecodeError('sync.invalid_request', `primary-key column index ${primaryKeyIndex} is outside the column table`);
193
+ }
194
+ if (!bitmapBit(presence, primaryKeyIndex)) {
195
+ throw new DecodeError('sync.invalid_request', `sparse row is missing the primary-key column ${primaryKey.name}`);
196
+ }
197
+ let presentCount = 0;
198
+ for (let i = 0; i < columns.length; i++) {
199
+ if (bitmapBit(presence, i))
200
+ presentCount++;
201
+ }
202
+ const nullLength = Math.ceil(presentCount / 8);
203
+ const nulls = reader.raw(nullLength);
204
+ for (let bit = presentCount; bit < nullLength * 8; bit++) {
205
+ if (bitmapBit(nulls, bit)) {
206
+ throw new DecodeError('sync.invalid_request', 'sparse row null bitmap sets a null bit for an absent column');
207
+ }
208
+ }
209
+ const values = [];
210
+ let present = 0;
211
+ for (let i = 0; i < columns.length; i++) {
212
+ const column = columns[i];
213
+ if (column === undefined || !bitmapBit(presence, i)) {
214
+ values.push(undefined);
215
+ continue;
216
+ }
217
+ const isNull = bitmapBit(nulls, present);
218
+ present++;
219
+ if (!isNull) {
220
+ values.push(readValue(reader, column));
221
+ continue;
222
+ }
223
+ if (!column.nullable) {
224
+ throw new DecodeError('sync.invalid_request', `null bit set for non-nullable column ${column.name}`);
225
+ }
226
+ values.push(null);
227
+ }
228
+ reader.expectFullyConsumed('sparse row payload');
229
+ return values;
230
+ }
112
231
  export function writeRow(writer, columns, values) {
113
232
  if (values.length !== columns.length) {
114
233
  throw new Error(`row value count ${values.length} does not match column count ${columns.length}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/core",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "Core protocol types and shared utilities for the Syncular sync framework",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
package/src/constants.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /** Protocol constants (SPEC.md §1.2, §9 are normative). */
2
2
 
3
3
  /** Latest SSP2 envelope wire version emitted by current clients. */
4
- export const PROTOCOL_WIRE_VERSION = 2;
4
+ export const PROTOCOL_WIRE_VERSION = 3;
5
5
 
6
- /** Oldest SSP2 envelope wire version accepted during the epoch rollout. */
7
- export const MINIMUM_PROTOCOL_WIRE_VERSION = 1;
6
+ /** Oldest SSP2 envelope wire version accepted. Wire version 3 changed the
7
+ * push payload to the sparse row codec (§2.4) and the conflict record shape
8
+ * (§6.3); versions 1 and 2 are rejected as unknown (§9). */
9
+ export const MINIMUM_PROTOCOL_WIRE_VERSION = 3;
8
10
 
9
11
  /** Wire versions implemented by the reference codec, oldest first. */
10
12
  export const SUPPORTED_PROTOCOL_WIRE_VERSIONS: readonly number[] =
package/src/crypto.ts CHANGED
@@ -48,6 +48,19 @@ export class DecryptError extends Error {
48
48
  readonly retryable = false;
49
49
  }
50
50
 
51
+ /**
52
+ * Client-local encrypt failure (SPEC.md §5.11, §10.3). Never on the wire.
53
+ * Raised at the encode-at-send seam when the key id cannot be resolved
54
+ * from the present columns or the stored local row, or the selected key
55
+ * is unknown. Not retryable unmodified. Distinct from {@link DecryptError},
56
+ * which is apply-seam only.
57
+ */
58
+ export class EncryptError extends Error {
59
+ override readonly name = 'EncryptError';
60
+ readonly code = 'client.encrypt_failed';
61
+ readonly retryable = false;
62
+ }
63
+
51
64
  /**
52
65
  * Injectable nonce source (SPEC.md §5.11 nonce discipline). Production uses
53
66
  * {@link secureRandomNonce}; crypto golden vectors inject a fixed nonce. A
package/src/message.ts CHANGED
@@ -122,6 +122,13 @@ export type PushOperationResult =
122
122
  message: string;
123
123
  serverVersion: number;
124
124
  serverRow: Uint8Array;
125
+ /**
126
+ * §6.3 (wire version 3): presence-bitmap layout over the row's columns,
127
+ * marking the present columns whose `column_version` exceeded the
128
+ * operation's `baseVersion`. A `baseVersion = 0` lost-insert conflict
129
+ * marks every present non-crdt column.
130
+ */
131
+ conflictColumns: Uint8Array;
125
132
  }
126
133
  | {
127
134
  opIndex: number;
@@ -422,6 +429,7 @@ function decodePushResult(r: ByteReader): PushResultFrame {
422
429
  message: r.str(),
423
430
  serverVersion: r.i64(),
424
431
  serverRow: r.bytes(),
432
+ conflictColumns: r.bytes(),
425
433
  });
426
434
  } else if (recordStatus === 3) {
427
435
  results.push({
@@ -971,6 +979,7 @@ function encodeFrame(
971
979
  w.str(result.message);
972
980
  w.i64(result.serverVersion);
973
981
  w.bytes(result.serverRow);
982
+ w.bytes(result.conflictColumns);
974
983
  } else {
975
984
  w.u8(3);
976
985
  w.str(result.code);
package/src/render.ts CHANGED
@@ -16,7 +16,7 @@ import type {
16
16
  SyncMessage,
17
17
  } from './message';
18
18
  import { decodeMessage } from './message';
19
- import type { RowColumn, RowValue } from './row-codec';
19
+ import type { RowColumn, RowValue, SparseRowValue } from './row-codec';
20
20
  import type { RowsSegment } from './segment';
21
21
  import { decodeRowsSegment, ROWS_SEGMENT_FORMAT_VERSION } from './segment';
22
22
 
@@ -106,6 +106,7 @@ function renderResult(result: PushOperationResult): JsonValue {
106
106
  message: result.message,
107
107
  serverVersion: result.serverVersion,
108
108
  serverRow: bytesToBase64(result.serverRow),
109
+ conflictColumns: bytesToBase64(result.conflictColumns),
109
110
  };
110
111
  }
111
112
  return {
@@ -321,17 +322,21 @@ function renderRowValue(column: RowColumn, value: RowValue): JsonValue {
321
322
  return value;
322
323
  }
323
324
 
325
+ function renderColumnTable(columns: readonly RowColumn[]): JsonValue {
326
+ return columns.map((column) => ({
327
+ name: column.name,
328
+ type: column.type,
329
+ nullable: column.nullable,
330
+ }));
331
+ }
332
+
324
333
  export function renderRowsSegmentValue(segment: RowsSegment): JsonValue {
325
334
  return {
326
335
  magic: 'SSG2',
327
336
  formatVersion: ROWS_SEGMENT_FORMAT_VERSION,
328
337
  table: segment.table,
329
338
  schemaVersion: segment.schemaVersion,
330
- columns: segment.columns.map((column) => ({
331
- name: column.name,
332
- type: column.type,
333
- nullable: column.nullable,
334
- })),
339
+ columns: renderColumnTable(segment.columns),
335
340
  blocks: segment.blocks.map((block) =>
336
341
  block.map((row) => ({
337
342
  serverVersion: row.serverVersion,
@@ -350,3 +355,25 @@ export function renderRowsSegmentValue(segment: RowsSegment): JsonValue {
350
355
  export function renderRowsSegment(bytes: Uint8Array): JsonValue {
351
356
  return renderRowsSegmentValue(decodeRowsSegment(bytes));
352
357
  }
358
+
359
+ /**
360
+ * Render a decoded sparse row (RFC §6.1, §11 rule 11): the column table plus
361
+ * `values` keyed by column name, carrying only the present columns, so an
362
+ * absent column has no key and a present NULL renders as JSON `null`.
363
+ */
364
+ export function renderSparseRowValue(
365
+ columns: readonly RowColumn[],
366
+ values: readonly SparseRowValue[],
367
+ ): JsonValue {
368
+ const present: Array<[string, JsonValue]> = [];
369
+ for (let i = 0; i < columns.length; i++) {
370
+ const column = columns[i];
371
+ const value = values[i];
372
+ if (column === undefined || value === undefined) continue;
373
+ present.push([column.name, renderRowValue(column, value)]);
374
+ }
375
+ return {
376
+ columns: renderColumnTable(columns),
377
+ values: Object.fromEntries(present),
378
+ };
379
+ }
package/src/row-codec.ts CHANGED
@@ -4,8 +4,13 @@
4
4
  * A row is a null bitmap of ceil(columnCount / 8) bytes (bit i set =
5
5
  * column i is NULL; LSB-first within each byte, byte i/8), followed by the
6
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.
7
+ * Used for `COMMIT` change payloads, rows-segment row data, and conflict
8
+ * `serverRow` values; push operation payloads use the sparse row codec
9
+ * (`encodeSparseRow` / `decodeSparseRow`) instead.
10
+ *
11
+ * A sparse row (RFC §6.1) is the column subset a push operation writes: a
12
+ * presence bitmap over all columns, a null bitmap over the present columns,
13
+ * then the non-null present values. Absent columns keep their stored value.
9
14
  */
10
15
  import { parseBlobRef } from './blob-ref';
11
16
  import { ByteReader, ByteWriter } from './bytes';
@@ -178,6 +183,156 @@ function readValue(reader: ByteReader, column: RowColumn): RowValue {
178
183
  }
179
184
  }
180
185
 
186
+ /** One bit of a bitmap: LSB-first within byte `index / 8`. */
187
+ function bitmapBit(bitmap: Uint8Array, index: number): boolean {
188
+ return (((bitmap[index >> 3] ?? 0) >> (index & 7)) & 1) !== 0;
189
+ }
190
+
191
+ /**
192
+ * A sparse-row slot (RFC §6.1): `undefined` when the column is absent from
193
+ * the payload (an apply leaves the stored value untouched), `null` when the
194
+ * column is present and NULL.
195
+ */
196
+ export type SparseRowValue = RowValue | undefined;
197
+
198
+ /**
199
+ * Encode one standalone sparse row (RFC §6.1, SPEC.md §2.4).
200
+ *
201
+ * `primaryKeyIndex` is the row's primary-key column; a payload without it is
202
+ * encoder misuse and throws, like a NULL in a non-nullable column.
203
+ */
204
+ export function encodeSparseRow(
205
+ columns: readonly RowColumn[],
206
+ primaryKeyIndex: number,
207
+ values: readonly SparseRowValue[],
208
+ ): Uint8Array {
209
+ if (values.length !== columns.length) {
210
+ throw new Error(
211
+ `row value count ${values.length} does not match column count ${columns.length}`,
212
+ );
213
+ }
214
+ const presence = new Uint8Array(Math.ceil(columns.length / 8));
215
+ let presentCount = 0;
216
+ for (let i = 0; i < columns.length; i++) {
217
+ if (values[i] === undefined) continue;
218
+ presence[i >> 3] = (presence[i >> 3] ?? 0) | (1 << (i & 7));
219
+ presentCount++;
220
+ }
221
+ const primaryKey = columns[primaryKeyIndex];
222
+ if (primaryKey === undefined) {
223
+ throw new Error(
224
+ `primary-key column index ${primaryKeyIndex} is outside the column table`,
225
+ );
226
+ }
227
+ if (!bitmapBit(presence, primaryKeyIndex)) {
228
+ throw new Error(
229
+ `sparse row is missing the primary-key column ${primaryKey.name}`,
230
+ );
231
+ }
232
+ const nulls = new Uint8Array(Math.ceil(presentCount / 8));
233
+ let present = 0;
234
+ for (let i = 0; i < columns.length; i++) {
235
+ const column = columns[i];
236
+ const value = values[i];
237
+ if (column === undefined || value === undefined) continue;
238
+ if (value === null) {
239
+ if (!column.nullable) {
240
+ throw new Error(`column ${column.name} is not nullable`);
241
+ }
242
+ nulls[present >> 3] = (nulls[present >> 3] ?? 0) | (1 << (present & 7));
243
+ }
244
+ present++;
245
+ }
246
+ const writer = new ByteWriter();
247
+ writer.raw(presence);
248
+ writer.raw(nulls);
249
+ for (let i = 0; i < columns.length; i++) {
250
+ const column = columns[i];
251
+ const value = values[i];
252
+ if (column === undefined || value === undefined || value === null) {
253
+ continue;
254
+ }
255
+ writeValue(writer, column, value);
256
+ }
257
+ return writer.finish();
258
+ }
259
+
260
+ /**
261
+ * Decode one standalone sparse row; the bytes must contain exactly one row.
262
+ *
263
+ * Decode checks, in order (each a decode error, RFC §6.1): a set presence
264
+ * padding bit, an absent primary-key column, a set null-bitmap padding bit
265
+ * (a null for an absent column), a null bit for a non-nullable column.
266
+ */
267
+ export function decodeSparseRow(
268
+ columns: readonly RowColumn[],
269
+ primaryKeyIndex: number,
270
+ bytes: Uint8Array,
271
+ ): SparseRowValue[] {
272
+ const reader = new ByteReader(bytes);
273
+ const presenceLength = Math.ceil(columns.length / 8);
274
+ const presence = reader.raw(presenceLength);
275
+ for (let bit = columns.length; bit < presenceLength * 8; bit++) {
276
+ if (bitmapBit(presence, bit)) {
277
+ throw new DecodeError(
278
+ 'sync.invalid_request',
279
+ 'sparse row presence bitmap has a set padding bit (non-canonical encoding)',
280
+ );
281
+ }
282
+ }
283
+ const primaryKey = columns[primaryKeyIndex];
284
+ if (primaryKey === undefined) {
285
+ throw new DecodeError(
286
+ 'sync.invalid_request',
287
+ `primary-key column index ${primaryKeyIndex} is outside the column table`,
288
+ );
289
+ }
290
+ if (!bitmapBit(presence, primaryKeyIndex)) {
291
+ throw new DecodeError(
292
+ 'sync.invalid_request',
293
+ `sparse row is missing the primary-key column ${primaryKey.name}`,
294
+ );
295
+ }
296
+ let presentCount = 0;
297
+ for (let i = 0; i < columns.length; i++) {
298
+ if (bitmapBit(presence, i)) presentCount++;
299
+ }
300
+ const nullLength = Math.ceil(presentCount / 8);
301
+ const nulls = reader.raw(nullLength);
302
+ for (let bit = presentCount; bit < nullLength * 8; bit++) {
303
+ if (bitmapBit(nulls, bit)) {
304
+ throw new DecodeError(
305
+ 'sync.invalid_request',
306
+ 'sparse row null bitmap sets a null bit for an absent column',
307
+ );
308
+ }
309
+ }
310
+ const values: SparseRowValue[] = [];
311
+ let present = 0;
312
+ for (let i = 0; i < columns.length; i++) {
313
+ const column = columns[i];
314
+ if (column === undefined || !bitmapBit(presence, i)) {
315
+ values.push(undefined);
316
+ continue;
317
+ }
318
+ const isNull = bitmapBit(nulls, present);
319
+ present++;
320
+ if (!isNull) {
321
+ values.push(readValue(reader, column));
322
+ continue;
323
+ }
324
+ if (!column.nullable) {
325
+ throw new DecodeError(
326
+ 'sync.invalid_request',
327
+ `null bit set for non-nullable column ${column.name}`,
328
+ );
329
+ }
330
+ values.push(null);
331
+ }
332
+ reader.expectFullyConsumed('sparse row payload');
333
+ return values;
334
+ }
335
+
181
336
  export function writeRow(
182
337
  writer: ByteWriter,
183
338
  columns: readonly RowColumn[],