@optimystic/db-core 1.0.0-beta.3 → 1.0.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 (171) hide show
  1. package/dist/src/cluster/structs.d.ts +30 -6
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js +5 -3
  4. package/dist/src/cluster/structs.js.map +1 -1
  5. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
  6. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
  8. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  10. package/dist/src/cohort-topic/wire/codec.js +8 -2
  11. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  12. package/dist/src/collection/collection.d.ts +439 -34
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +843 -148
  15. package/dist/src/collection/collection.js.map +1 -1
  16. package/dist/src/collection/struct.d.ts +109 -4
  17. package/dist/src/collection/struct.d.ts.map +1 -1
  18. package/dist/src/collection/struct.js +88 -1
  19. package/dist/src/collection/struct.js.map +1 -1
  20. package/dist/src/collections/diary/diary.d.ts +6 -2
  21. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  22. package/dist/src/collections/diary/diary.js +5 -2
  23. package/dist/src/collections/diary/diary.js.map +1 -1
  24. package/dist/src/collections/tree/struct.d.ts +124 -16
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +86 -11
  27. package/dist/src/collections/tree/struct.js.map +1 -1
  28. package/dist/src/collections/tree/tree.d.ts +13 -3
  29. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  30. package/dist/src/collections/tree/tree.js +106 -40
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  37. package/dist/src/matchmaking/wire.js +8 -2
  38. package/dist/src/matchmaking/wire.js.map +1 -1
  39. package/dist/src/network/durability.d.ts +45 -0
  40. package/dist/src/network/durability.d.ts.map +1 -0
  41. package/dist/src/network/durability.js +114 -0
  42. package/dist/src/network/durability.js.map +1 -0
  43. package/dist/src/network/i-key-network.d.ts +14 -6
  44. package/dist/src/network/i-key-network.d.ts.map +1 -1
  45. package/dist/src/network/index.d.ts +3 -0
  46. package/dist/src/network/index.d.ts.map +1 -1
  47. package/dist/src/network/index.js +3 -0
  48. package/dist/src/network/index.js.map +1 -1
  49. package/dist/src/network/lineage.d.ts +39 -0
  50. package/dist/src/network/lineage.d.ts.map +1 -0
  51. package/dist/src/network/lineage.js +61 -0
  52. package/dist/src/network/lineage.js.map +1 -0
  53. package/dist/src/network/routing-key.d.ts +24 -0
  54. package/dist/src/network/routing-key.d.ts.map +1 -0
  55. package/dist/src/network/routing-key.js +9 -0
  56. package/dist/src/network/routing-key.js.map +1 -0
  57. package/dist/src/network/stale-failure.d.ts +5 -1
  58. package/dist/src/network/stale-failure.d.ts.map +1 -1
  59. package/dist/src/network/stale-failure.js +5 -1
  60. package/dist/src/network/stale-failure.js.map +1 -1
  61. package/dist/src/network/struct.d.ts +151 -8
  62. package/dist/src/network/struct.d.ts.map +1 -1
  63. package/dist/src/network/struct.js.map +1 -1
  64. package/dist/src/reactivity/wire.d.ts.map +1 -1
  65. package/dist/src/reactivity/wire.js +8 -2
  66. package/dist/src/reactivity/wire.js.map +1 -1
  67. package/dist/src/testing/test-transactor.d.ts +64 -7
  68. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  69. package/dist/src/testing/test-transactor.js +150 -23
  70. package/dist/src/testing/test-transactor.js.map +1 -1
  71. package/dist/src/transaction/coordinator.d.ts +141 -21
  72. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  73. package/dist/src/transaction/coordinator.js +408 -122
  74. package/dist/src/transaction/coordinator.js.map +1 -1
  75. package/dist/src/transaction/errors.d.ts +39 -10
  76. package/dist/src/transaction/errors.d.ts.map +1 -1
  77. package/dist/src/transaction/errors.js +34 -9
  78. package/dist/src/transaction/errors.js.map +1 -1
  79. package/dist/src/transaction/index.d.ts +1 -0
  80. package/dist/src/transaction/index.d.ts.map +1 -1
  81. package/dist/src/transaction/index.js.map +1 -1
  82. package/dist/src/transactor/block-floors.d.ts +78 -0
  83. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  84. package/dist/src/transactor/block-floors.js +62 -0
  85. package/dist/src/transactor/block-floors.js.map +1 -0
  86. package/dist/src/transactor/change-notifier.d.ts +43 -0
  87. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  88. package/dist/src/transactor/change-notifier.js +3 -0
  89. package/dist/src/transactor/change-notifier.js.map +1 -1
  90. package/dist/src/transactor/index.d.ts +1 -0
  91. package/dist/src/transactor/index.d.ts.map +1 -1
  92. package/dist/src/transactor/index.js +1 -0
  93. package/dist/src/transactor/index.js.map +1 -1
  94. package/dist/src/transactor/network-transactor.d.ts +43 -6
  95. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  96. package/dist/src/transactor/network-transactor.js +279 -86
  97. package/dist/src/transactor/network-transactor.js.map +1 -1
  98. package/dist/src/transactor/transactor-source.d.ts +94 -4
  99. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  100. package/dist/src/transactor/transactor-source.js +120 -42
  101. package/dist/src/transactor/transactor-source.js.map +1 -1
  102. package/dist/src/transactor/transactor.d.ts +14 -1
  103. package/dist/src/transactor/transactor.d.ts.map +1 -1
  104. package/dist/src/transform/atomic.d.ts.map +1 -1
  105. package/dist/src/transform/atomic.js +12 -9
  106. package/dist/src/transform/atomic.js.map +1 -1
  107. package/dist/src/transform/base-pins.d.ts +50 -17
  108. package/dist/src/transform/base-pins.d.ts.map +1 -1
  109. package/dist/src/transform/base-pins.js +58 -11
  110. package/dist/src/transform/base-pins.js.map +1 -1
  111. package/dist/src/transform/cache-source.d.ts +75 -11
  112. package/dist/src/transform/cache-source.d.ts.map +1 -1
  113. package/dist/src/transform/cache-source.js +162 -36
  114. package/dist/src/transform/cache-source.js.map +1 -1
  115. package/dist/src/transform/digest.d.ts +8 -1
  116. package/dist/src/transform/digest.d.ts.map +1 -1
  117. package/dist/src/transform/digest.js +19 -9
  118. package/dist/src/transform/digest.js.map +1 -1
  119. package/dist/src/transform/helpers.d.ts +6 -2
  120. package/dist/src/transform/helpers.d.ts.map +1 -1
  121. package/dist/src/transform/helpers.js +8 -3
  122. package/dist/src/transform/helpers.js.map +1 -1
  123. package/dist/src/transform/tracker.d.ts +94 -14
  124. package/dist/src/transform/tracker.d.ts.map +1 -1
  125. package/dist/src/transform/tracker.js +222 -68
  126. package/dist/src/transform/tracker.js.map +1 -1
  127. package/dist/src/utility/structural-equals.d.ts +38 -0
  128. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  129. package/dist/src/utility/structural-equals.js +77 -0
  130. package/dist/src/utility/structural-equals.js.map +1 -0
  131. package/package.json +2 -2
  132. package/src/cluster/structs.ts +28 -7
  133. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  134. package/src/cohort-topic/wire/codec.ts +222 -216
  135. package/src/collection/collection.ts +960 -157
  136. package/src/collection/struct.ts +99 -4
  137. package/src/collections/diary/diary.ts +71 -68
  138. package/src/collections/tree/struct.ts +155 -23
  139. package/src/collections/tree/tree.ts +127 -40
  140. package/src/index.ts +1 -1
  141. package/src/matchmaking/wire.ts +8 -2
  142. package/src/network/durability.ts +123 -0
  143. package/src/network/i-key-network.ts +60 -52
  144. package/src/network/index.ts +10 -7
  145. package/src/network/lineage.ts +80 -0
  146. package/src/network/routing-key.ts +27 -0
  147. package/src/network/stale-failure.ts +5 -1
  148. package/src/network/struct.ts +156 -8
  149. package/src/reactivity/wire.ts +230 -224
  150. package/src/testing/test-transactor.ts +152 -24
  151. package/src/transaction/coordinator.ts +470 -131
  152. package/src/transaction/errors.ts +34 -9
  153. package/src/transaction/index.ts +1 -0
  154. package/src/transactor/block-floors.ts +105 -0
  155. package/src/transactor/change-notifier.ts +129 -80
  156. package/src/transactor/index.ts +1 -0
  157. package/src/transactor/network-transactor.ts +297 -89
  158. package/src/transactor/transactor-source.ts +131 -42
  159. package/src/transactor/transactor.ts +13 -1
  160. package/src/transform/atomic.ts +25 -22
  161. package/src/transform/base-pins.ts +76 -18
  162. package/src/transform/cache-source.ts +327 -194
  163. package/src/transform/digest.ts +21 -10
  164. package/src/transform/helpers.ts +8 -3
  165. package/src/transform/tracker.ts +423 -270
  166. package/src/utility/structural-equals.ts +72 -0
  167. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  168. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  169. package/dist/src/utility/block-id-to-bytes.js +0 -7
  170. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  171. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -1,224 +1,230 @@
1
- /**
2
- * Reactivity — V1 wire types, codecs, and per-message validation.
3
- *
4
- * Transcribed from `docs/reactivity.md` §Wire formats. Reactivity reuses the cohort-topic `RegisterV1`
5
- * / `RenewV1` envelopes **unchanged**; the reactivity-specific shapes live in two places:
6
- *
7
- * 1. **Subscribe app payload** ({@link SubscribeAppPayloadV1}) carried opaquely inside
8
- * `RegisterV1.appPayload`. Serialized to UTF-8 JSON bytes and handed to the cohort-topic
9
- * {@link import("../cohort-topic/service.js").RegisterRequest}`.appPayload` slot, which base64url-encodes
10
- * it on the wire. It is therefore **not** length-framed here — the cohort-topic `RegisterV1` frame
11
- * wraps it. The subscribe RPC's authentication (peer-key signature over `correlationId` + `timestamp`)
12
- * is the cohort-topic `RegisterV1` envelope's job, so the payload itself carries no signature.
13
- *
14
- * 2. **Notification** ({@link NotificationV1}) sent as a standalone RPC over the reactivity application
15
- * protocol. It rides the same length-prefixed UTF-8 JSON framing as cohort-topic messages
16
- * ({@link encodeCohortMessage}). Its `sig` is **bit-for-bit** the commit certificate's threshold
17
- * signature — reactivity never re-signs (`docs/reactivity.md` §Notification origination).
18
- *
19
- * Conventions (matching the cohort-topic wire conventions): all JSON, byte fields base64url (no
20
- * padding), unix-millisecond timestamps, per-message structural validation on decode, byte-fidelity
21
- * round-trips (encode→decode→encode is stable). `collectionId` / `tailId` travel as the collection's
22
- * base64url block ids verbatim. The `BackfillV1` / `ResumeV1` codecs belong to the sibling tickets.
23
- */
24
-
25
- import {
26
- decodeCohortMessage,
27
- encodeCohortMessage,
28
- DEFAULT_MAX_MESSAGE_BYTES,
29
- } from "../cohort-topic/wire/codec.js";
30
- import {
31
- asObject,
32
- b64urlField,
33
- failWire as fail,
34
- reqBool,
35
- reqFiniteNumber,
36
- reqIntInRange,
37
- reqString,
38
- reqStringArray,
39
- requireV1,
40
- } from "./wire-validate.js";
41
-
42
- const utf8Encoder = new TextEncoder();
43
- const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
44
-
45
- /** Ceiling for an opaque subscribe app payload, guarding decode allocation. */
46
- export const DEFAULT_MAX_SUBSCRIBE_PAYLOAD_BYTES = 64 * 1024;
47
-
48
- // --- Subscribe app payload (carried inside cohort-topic RegisterV1.appPayload) ---
49
-
50
- /** A subscriber's registration payload (`RegisterV1.appPayload`, `kind == "reactivity"`). */
51
- export interface SubscribeAppPayloadV1 {
52
- kind: "reactivity";
53
- /** Stable collection identity, base64url (the collection's id block id). */
54
- collectionId: string;
55
- /** Tail block id at registration time, base64url (the subscriber-side rotation detector). */
56
- tailIdAtAttach: string;
57
- /** Last revision the subscriber already holds; `0` for a fresh subscribe. */
58
- lastKnownRev: number;
59
- /** Max delta bytes the subscriber accepts; `0` declines delta payloads (Edge). */
60
- deltaMaxBytes: number;
61
- }
62
-
63
- // --- Notification (length-framed RPC) ---
64
-
65
- /** The tail cohort's rotation pre-announce, carried inside (and signed by) a {@link NotificationV1}. */
66
- export interface RotationHintV1 {
67
- /** New tail block id the topic anchor is rotating to, base64url. */
68
- newTailId: string;
69
- /** Revision at which the new tail becomes effective. */
70
- effectiveAtRevision: number;
71
- }
72
-
73
- /** A signed change notification fanned out through the reactivity tree (`docs/reactivity.md` §Notification origination). */
74
- export interface NotificationV1 {
75
- v: 1;
76
- /** Collection id, base64url. */
77
- collectionId: string;
78
- /** Tail block id the reactivity topic is anchored on, base64url. */
79
- tailId: string;
80
- /** Per-collection monotonic revision. */
81
- revision: number;
82
- /**
83
- * The commit-vote **signed payload** `utf8(commitHash + ":approve")`, base64url — the exact bytes the
84
- * threshold signature {@link sig} was computed over. A subscriber threshold-verifies {@link sig} over
85
- * `b64urlToBytes(digest)`, so this reproduces the signed image against real Ed25519.
86
- */
87
- digest: string;
88
- /** Optional bounded delta, base64url; omitted when `delta_max == 0` or the collection declines it. */
89
- delta?: string;
90
- /** Unix ms. */
91
- timestamp: number;
92
- /** Threshold signature, base64url — **= the commit cert**, never re-signed. */
93
- sig: string;
94
- /** PeerIds contributing to {@link sig}, base64url of the cohort member-id bytes. */
95
- signers: string[];
96
- /** Tail-rotation pre-announce, when this notification carries one (rotation ticket fills it). */
97
- rotationHint?: RotationHintV1;
98
- /**
99
- * `true` iff this notification announces a durable **invalidation** (a reversal of a previously
100
- * committed action proven invalid by dispute) rather than an ordinary commit
101
- * (`docs/reactivity.md` §Notification origination, `docs/right-is-right.md` §Durable Invalidation).
102
- * An invalidation is a committed collection change like any other and rides the same notification
103
- * path, reusing the **invalidation's** commit cert as {@link sig} (never re-signed) — so it verifies
104
- * against the tail cohort exactly like a commit notification. The flag is a hint that lets a
105
- * subscriber react (drop derived results + resubmit) rather than merely refresh; a subscriber that
106
- * ignores it still re-reads the authoritative reverted state. Omitted (falsy) on commit notifications.
107
- */
108
- invalidation?: boolean;
109
- /**
110
- * When {@link invalidation} is set, the original action id that was reversed
111
- * (`InvalidationEntry.invalidatedActionId`). Lets a client dedup/coalesce the several cascade
112
- * notifications a single dispute can produce by the action they reverse. Omitted on commits.
113
- */
114
- invalidatedActionId?: string;
115
- }
116
-
117
- // --- validation helpers ---
118
- // Generic primitives live in ./wire-validate.js (shared across the reactivity codecs); the
119
- // notification-specific narrowing stays here next to the type it produces.
120
-
121
- function validateRotationHint(value: unknown, what: string): RotationHintV1 {
122
- const obj = asObject(value, what);
123
- return {
124
- newTailId: b64urlField(reqString(obj, "newTailId", what), "newTailId", what),
125
- effectiveAtRevision: reqIntInRange(obj, "effectiveAtRevision", what, 0),
126
- };
127
- }
128
-
129
- // --- Subscribe app payload (opaque RegisterV1.appPayload bytes) ---
130
-
131
- /** Narrow an already-parsed value to {@link SubscribeAppPayloadV1}, throwing on any defect. */
132
- export function validateSubscribeAppPayloadV1(value: unknown): SubscribeAppPayloadV1 {
133
- const what = "SubscribeAppPayloadV1";
134
- const obj = asObject(value, what);
135
- if (obj["kind"] !== "reactivity") {
136
- fail(`${what}: field "kind" must be exactly "reactivity"`);
137
- }
138
- return {
139
- kind: "reactivity",
140
- collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
141
- tailIdAtAttach: b64urlField(reqString(obj, "tailIdAtAttach", what), "tailIdAtAttach", what),
142
- lastKnownRev: reqIntInRange(obj, "lastKnownRev", what, 0),
143
- deltaMaxBytes: reqIntInRange(obj, "deltaMaxBytes", what, 0),
144
- };
145
- }
146
-
147
- /** Serialize a {@link SubscribeAppPayloadV1} to the opaque UTF-8 JSON bytes for `RegisterV1.appPayload`. */
148
- export function encodeSubscribeAppPayload(payload: SubscribeAppPayloadV1): Uint8Array {
149
- return utf8Encoder.encode(JSON.stringify(validateSubscribeAppPayloadV1(payload)));
150
- }
151
-
152
- /** Decode opaque `RegisterV1.appPayload` bytes back to a validated {@link SubscribeAppPayloadV1}. */
153
- export function decodeSubscribeAppPayload(bytes: Uint8Array, maxBytes: number = DEFAULT_MAX_SUBSCRIBE_PAYLOAD_BYTES): SubscribeAppPayloadV1 {
154
- return validateSubscribeAppPayloadV1(parseJsonBytes(bytes, maxBytes, "SubscribeAppPayloadV1"));
155
- }
156
-
157
- /** Parse opaque (non-framed) UTF-8 JSON payload bytes, rejecting oversized/invalid input. */
158
- function parseJsonBytes(bytes: Uint8Array, maxBytes: number, what: string): unknown {
159
- if (bytes.length > maxBytes) {
160
- fail(`${what}: payload ${bytes.length} exceeds max ${maxBytes} bytes`);
161
- }
162
- let text: string;
163
- try {
164
- text = utf8Decoder.decode(bytes);
165
- } catch {
166
- fail(`${what}: payload is not valid UTF-8`);
167
- }
168
- try {
169
- return JSON.parse(text) as unknown;
170
- } catch {
171
- fail(`${what}: payload is not valid JSON`);
172
- }
173
- }
174
-
175
- // --- Notification (length-framed) ---
176
-
177
- /** Narrow an already-parsed value to {@link NotificationV1}, throwing on any defect. */
178
- export function validateNotificationV1(value: unknown): NotificationV1 {
179
- const what = "NotificationV1";
180
- const obj = asObject(value, what);
181
- requireV1(obj, what);
182
- const signers = reqStringArray(obj, "signers", what);
183
- const out: NotificationV1 = {
184
- v: 1,
185
- collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
186
- tailId: b64urlField(reqString(obj, "tailId", what), "tailId", what),
187
- revision: reqIntInRange(obj, "revision", what, 0),
188
- digest: b64urlField(reqString(obj, "digest", what), "digest", what),
189
- timestamp: reqFiniteNumber(obj, "timestamp", what),
190
- sig: b64urlField(reqString(obj, "sig", what), "sig", what),
191
- signers: signers.map((s, i) => b64urlField(s, `signers[${i}]`, what)),
192
- };
193
- if (obj["delta"] !== undefined) {
194
- out.delta = b64urlField(reqString(obj, "delta", what), "delta", what);
195
- }
196
- if (obj["rotationHint"] !== undefined) {
197
- out.rotationHint = validateRotationHint(obj["rotationHint"], `${what}.rotationHint`);
198
- }
199
- if (obj["invalidation"] !== undefined) {
200
- out.invalidation = reqBool(obj, "invalidation", what);
201
- }
202
- if (obj["invalidatedActionId"] !== undefined) {
203
- out.invalidatedActionId = reqString(obj, "invalidatedActionId", what);
204
- }
205
- return out;
206
- }
207
-
208
- /** Validate an array of {@link NotificationV1} (the `entries`/`recentEntries` carried in backfill/resume replies). */
209
- export function validateNotificationArray(value: unknown, what: string): NotificationV1[] {
210
- if (!Array.isArray(value)) {
211
- fail(`${what}: field "entries" must be an array`);
212
- }
213
- return value.map((entry) => validateNotificationV1(entry));
214
- }
215
-
216
- /** Encode a {@link NotificationV1} as a length-prefixed UTF-8 JSON frame. */
217
- export function encodeNotificationV1(msg: NotificationV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
218
- return encodeCohortMessage(validateNotificationV1(msg), maxMessageBytes);
219
- }
220
-
221
- /** Decode a length-prefixed {@link NotificationV1} frame. */
222
- export function decodeNotificationV1(bytes: Uint8Array, maxMessageBytes?: number): NotificationV1 {
223
- return validateNotificationV1(decodeCohortMessage(bytes, maxMessageBytes));
224
- }
1
+ /**
2
+ * Reactivity — V1 wire types, codecs, and per-message validation.
3
+ *
4
+ * Transcribed from `docs/reactivity.md` §Wire formats. Reactivity reuses the cohort-topic `RegisterV1`
5
+ * / `RenewV1` envelopes **unchanged**; the reactivity-specific shapes live in two places:
6
+ *
7
+ * 1. **Subscribe app payload** ({@link SubscribeAppPayloadV1}) carried opaquely inside
8
+ * `RegisterV1.appPayload`. Serialized to UTF-8 JSON bytes and handed to the cohort-topic
9
+ * {@link import("../cohort-topic/service.js").RegisterRequest}`.appPayload` slot, which base64url-encodes
10
+ * it on the wire. It is therefore **not** length-framed here — the cohort-topic `RegisterV1` frame
11
+ * wraps it. The subscribe RPC's authentication (peer-key signature over `correlationId` + `timestamp`)
12
+ * is the cohort-topic `RegisterV1` envelope's job, so the payload itself carries no signature.
13
+ *
14
+ * 2. **Notification** ({@link NotificationV1}) sent as a standalone RPC over the reactivity application
15
+ * protocol. It rides the same length-prefixed UTF-8 JSON framing as cohort-topic messages
16
+ * ({@link encodeCohortMessage}). Its `sig` is **bit-for-bit** the commit certificate's threshold
17
+ * signature — reactivity never re-signs (`docs/reactivity.md` §Notification origination).
18
+ *
19
+ * Conventions (matching the cohort-topic wire conventions): all JSON, byte fields base64url (no
20
+ * padding), unix-millisecond timestamps, per-message structural validation on decode, byte-fidelity
21
+ * round-trips (encode→decode→encode is stable). `collectionId` / `tailId` travel as the collection's
22
+ * base64url block ids verbatim. The `BackfillV1` / `ResumeV1` codecs belong to the sibling tickets.
23
+ */
24
+
25
+ import {
26
+ decodeCohortMessage,
27
+ encodeCohortMessage,
28
+ DEFAULT_MAX_MESSAGE_BYTES,
29
+ } from "../cohort-topic/wire/codec.js";
30
+ import {
31
+ asObject,
32
+ b64urlField,
33
+ failWire as fail,
34
+ reqBool,
35
+ reqFiniteNumber,
36
+ reqIntInRange,
37
+ reqString,
38
+ reqStringArray,
39
+ requireV1,
40
+ } from "./wire-validate.js";
41
+
42
+ const utf8Encoder = new TextEncoder();
43
+ // Built on first use, not at module load: Hermes (React Native) has no native `TextDecoder`, so a
44
+ // module-scope construction fails the import itself whenever it runs ahead of the host's polyfill.
45
+ let utf8DecoderInstance: TextDecoder | undefined;
46
+ const utf8Decoder = (): TextDecoder => utf8DecoderInstance ??= new TextDecoder("utf-8", { fatal: true });
47
+
48
+ /** Ceiling for an opaque subscribe app payload, guarding decode allocation. */
49
+ export const DEFAULT_MAX_SUBSCRIBE_PAYLOAD_BYTES = 64 * 1024;
50
+
51
+ // --- Subscribe app payload (carried inside cohort-topic RegisterV1.appPayload) ---
52
+
53
+ /** A subscriber's registration payload (`RegisterV1.appPayload`, `kind == "reactivity"`). */
54
+ export interface SubscribeAppPayloadV1 {
55
+ kind: "reactivity";
56
+ /** Stable collection identity, base64url (the collection's id block id). */
57
+ collectionId: string;
58
+ /** Tail block id at registration time, base64url (the subscriber-side rotation detector). */
59
+ tailIdAtAttach: string;
60
+ /** Last revision the subscriber already holds; `0` for a fresh subscribe. */
61
+ lastKnownRev: number;
62
+ /** Max delta bytes the subscriber accepts; `0` declines delta payloads (Edge). */
63
+ deltaMaxBytes: number;
64
+ }
65
+
66
+ // --- Notification (length-framed RPC) ---
67
+
68
+ /** The tail cohort's rotation pre-announce, carried inside (and signed by) a {@link NotificationV1}. */
69
+ export interface RotationHintV1 {
70
+ /** New tail block id the topic anchor is rotating to, base64url. */
71
+ newTailId: string;
72
+ /** Revision at which the new tail becomes effective. */
73
+ effectiveAtRevision: number;
74
+ }
75
+
76
+ /** A signed change notification fanned out through the reactivity tree (`docs/reactivity.md` §Notification origination). */
77
+ export interface NotificationV1 {
78
+ v: 1;
79
+ /** Collection id, base64url. */
80
+ collectionId: string;
81
+ /** Tail block id the reactivity topic is anchored on, base64url. */
82
+ tailId: string;
83
+ /** Per-collection monotonic revision. */
84
+ revision: number;
85
+ /**
86
+ * The commit-vote **signed payload** `utf8(commitHash + ":approve")`, base64url — the exact bytes the
87
+ * threshold signature {@link sig} was computed over. A subscriber threshold-verifies {@link sig} over
88
+ * `b64urlToBytes(digest)`, so this reproduces the signed image against real Ed25519.
89
+ */
90
+ digest: string;
91
+ /** Optional bounded delta, base64url; omitted when `delta_max == 0` or the collection declines it. */
92
+ delta?: string;
93
+ /** Unix ms. */
94
+ timestamp: number;
95
+ /** Threshold signature, base64url — **= the commit cert**, never re-signed. */
96
+ sig: string;
97
+ /** PeerIds contributing to {@link sig}, base64url of the cohort member-id bytes. */
98
+ signers: string[];
99
+ /** Tail-rotation pre-announce, when this notification carries one (rotation ticket fills it). */
100
+ rotationHint?: RotationHintV1;
101
+ /**
102
+ * `true` iff this notification announces a durable **invalidation** (a reversal of a previously
103
+ * committed action proven invalid by dispute) rather than an ordinary commit
104
+ * (`docs/reactivity.md` §Notification origination, `docs/right-is-right.md` §Durable Invalidation).
105
+ * An invalidation is a committed collection change like any other and rides the same notification
106
+ * path, reusing the **invalidation's** commit cert as {@link sig} (never re-signed) so it verifies
107
+ * against the tail cohort exactly like a commit notification. The flag is a hint that lets a
108
+ * subscriber react (drop derived results + resubmit) rather than merely refresh; a subscriber that
109
+ * ignores it still re-reads the authoritative reverted state. Omitted (falsy) on commit notifications.
110
+ */
111
+ invalidation?: boolean;
112
+ /**
113
+ * When {@link invalidation} is set, the original action id that was reversed
114
+ * (`InvalidationEntry.invalidatedActionId`). Lets a client dedup/coalesce the several cascade
115
+ * notifications a single dispute can produce by the action they reverse. Omitted on commits.
116
+ */
117
+ invalidatedActionId?: string;
118
+ }
119
+
120
+ // --- validation helpers ---
121
+ // Generic primitives live in ./wire-validate.js (shared across the reactivity codecs); the
122
+ // notification-specific narrowing stays here next to the type it produces.
123
+
124
+ function validateRotationHint(value: unknown, what: string): RotationHintV1 {
125
+ const obj = asObject(value, what);
126
+ return {
127
+ newTailId: b64urlField(reqString(obj, "newTailId", what), "newTailId", what),
128
+ effectiveAtRevision: reqIntInRange(obj, "effectiveAtRevision", what, 0),
129
+ };
130
+ }
131
+
132
+ // --- Subscribe app payload (opaque RegisterV1.appPayload bytes) ---
133
+
134
+ /** Narrow an already-parsed value to {@link SubscribeAppPayloadV1}, throwing on any defect. */
135
+ export function validateSubscribeAppPayloadV1(value: unknown): SubscribeAppPayloadV1 {
136
+ const what = "SubscribeAppPayloadV1";
137
+ const obj = asObject(value, what);
138
+ if (obj["kind"] !== "reactivity") {
139
+ fail(`${what}: field "kind" must be exactly "reactivity"`);
140
+ }
141
+ return {
142
+ kind: "reactivity",
143
+ collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
144
+ tailIdAtAttach: b64urlField(reqString(obj, "tailIdAtAttach", what), "tailIdAtAttach", what),
145
+ lastKnownRev: reqIntInRange(obj, "lastKnownRev", what, 0),
146
+ deltaMaxBytes: reqIntInRange(obj, "deltaMaxBytes", what, 0),
147
+ };
148
+ }
149
+
150
+ /** Serialize a {@link SubscribeAppPayloadV1} to the opaque UTF-8 JSON bytes for `RegisterV1.appPayload`. */
151
+ export function encodeSubscribeAppPayload(payload: SubscribeAppPayloadV1): Uint8Array {
152
+ return utf8Encoder.encode(JSON.stringify(validateSubscribeAppPayloadV1(payload)));
153
+ }
154
+
155
+ /** Decode opaque `RegisterV1.appPayload` bytes back to a validated {@link SubscribeAppPayloadV1}. */
156
+ export function decodeSubscribeAppPayload(bytes: Uint8Array, maxBytes: number = DEFAULT_MAX_SUBSCRIBE_PAYLOAD_BYTES): SubscribeAppPayloadV1 {
157
+ return validateSubscribeAppPayloadV1(parseJsonBytes(bytes, maxBytes, "SubscribeAppPayloadV1"));
158
+ }
159
+
160
+ /** Parse opaque (non-framed) UTF-8 JSON payload bytes, rejecting oversized/invalid input. */
161
+ function parseJsonBytes(bytes: Uint8Array, maxBytes: number, what: string): unknown {
162
+ if (bytes.length > maxBytes) {
163
+ fail(`${what}: payload ${bytes.length} exceeds max ${maxBytes} bytes`);
164
+ }
165
+ // Fetched outside the try, so a missing `TextDecoder` polyfill surfaces as itself rather than as
166
+ // "not valid UTF-8".
167
+ const decoder = utf8Decoder();
168
+ let text: string;
169
+ try {
170
+ text = decoder.decode(bytes);
171
+ } catch {
172
+ fail(`${what}: payload is not valid UTF-8`);
173
+ }
174
+ try {
175
+ return JSON.parse(text) as unknown;
176
+ } catch {
177
+ fail(`${what}: payload is not valid JSON`);
178
+ }
179
+ }
180
+
181
+ // --- Notification (length-framed) ---
182
+
183
+ /** Narrow an already-parsed value to {@link NotificationV1}, throwing on any defect. */
184
+ export function validateNotificationV1(value: unknown): NotificationV1 {
185
+ const what = "NotificationV1";
186
+ const obj = asObject(value, what);
187
+ requireV1(obj, what);
188
+ const signers = reqStringArray(obj, "signers", what);
189
+ const out: NotificationV1 = {
190
+ v: 1,
191
+ collectionId: b64urlField(reqString(obj, "collectionId", what), "collectionId", what),
192
+ tailId: b64urlField(reqString(obj, "tailId", what), "tailId", what),
193
+ revision: reqIntInRange(obj, "revision", what, 0),
194
+ digest: b64urlField(reqString(obj, "digest", what), "digest", what),
195
+ timestamp: reqFiniteNumber(obj, "timestamp", what),
196
+ sig: b64urlField(reqString(obj, "sig", what), "sig", what),
197
+ signers: signers.map((s, i) => b64urlField(s, `signers[${i}]`, what)),
198
+ };
199
+ if (obj["delta"] !== undefined) {
200
+ out.delta = b64urlField(reqString(obj, "delta", what), "delta", what);
201
+ }
202
+ if (obj["rotationHint"] !== undefined) {
203
+ out.rotationHint = validateRotationHint(obj["rotationHint"], `${what}.rotationHint`);
204
+ }
205
+ if (obj["invalidation"] !== undefined) {
206
+ out.invalidation = reqBool(obj, "invalidation", what);
207
+ }
208
+ if (obj["invalidatedActionId"] !== undefined) {
209
+ out.invalidatedActionId = reqString(obj, "invalidatedActionId", what);
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /** Validate an array of {@link NotificationV1} (the `entries`/`recentEntries` carried in backfill/resume replies). */
215
+ export function validateNotificationArray(value: unknown, what: string): NotificationV1[] {
216
+ if (!Array.isArray(value)) {
217
+ fail(`${what}: field "entries" must be an array`);
218
+ }
219
+ return value.map((entry) => validateNotificationV1(entry));
220
+ }
221
+
222
+ /** Encode a {@link NotificationV1} as a length-prefixed UTF-8 JSON frame. */
223
+ export function encodeNotificationV1(msg: NotificationV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
224
+ return encodeCohortMessage(validateNotificationV1(msg), maxMessageBytes);
225
+ }
226
+
227
+ /** Decode a length-prefixed {@link NotificationV1} frame. */
228
+ export function decodeNotificationV1(bytes: Uint8Array, maxMessageBytes?: number): NotificationV1 {
229
+ return validateNotificationV1(decodeCohortMessage(bytes, maxMessageBytes));
230
+ }