@optimystic/db-core 1.0.0-beta.3 → 1.1.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,4 +1,4 @@
1
- import type { IBlock, BlockId, Action } from "../index.js";
1
+ import type { IBlock, BlockId, Action, WriteDurability } from "../index.js";
2
2
  import type { IChainHeader } from "../chain/chain-nodes.js";
3
3
  import type { RandFn } from "../utility/backoff.js";
4
4
 
@@ -48,7 +48,12 @@ export interface SyncOptions {
48
48
 
49
49
  /** Thrown by {@link ICollection.sync} / {@link ICollection.updateAndSync} when the retry budget
50
50
  * (attempt count or deadline) is exhausted while the transactor keeps returning stale failures.
51
- * Catchable so callers can surface a clear "gave up syncing" condition instead of hanging. */
51
+ * Catchable so callers can surface a clear "gave up syncing" condition instead of hanging.
52
+ *
53
+ * It says the write was NOT SAVED; it does not prove nothing was stored. A write's log tail is
54
+ * committed before its other blocks, and the attempt that spent the budget is not followed by a
55
+ * refresh, so that attempt's log entry may be standing in the log with none of its data — the state
56
+ * {@link TornActionError} names when a refresh does get to see it. */
52
57
  export class SyncRetryExhaustedError extends Error {
53
58
  constructor(
54
59
  readonly collectionId: CollectionId,
@@ -111,6 +116,91 @@ export class SyncRevisionStalledError extends SyncRetryExhaustedError {
111
116
  }
112
117
  }
113
118
 
119
+ /** Why a half-landed write could not be finished (see {@link TornActionError}). This is the CAUSE
120
+ * of giving up; whether the write is then definitely not saved is a separate fact, carried by
121
+ * {@link TornActionError.final} — every reason can arrive with either value.
122
+ *
123
+ * - `rival-holds-revision` — finishing was refused because a DIFFERENT action holds a revision past
124
+ * the one the write needed on some block. That alone does NOT mean the write is lost: a later
125
+ * revision is often built ON the write (a rival that read it and added to it), and a write every
126
+ * block of which was built upon is reported SAVED, never by this error. It is raised only once
127
+ * the blocks' own history was asked (`ITransactor.getLineage`) and did not vouch for every block.
128
+ * - `completion-refused` — finishing was attempted and refused for a cause that can clear on its
129
+ * own (another write in flight on the block, the revision not yet held by a majority). The write
130
+ * paths retry this inside their own budget, so seeing it means that budget ran out.
131
+ * - `transforms-not-held` — the write's own log entry was found with blocks still missing, but this
132
+ * collection no longer holds the transforms that would finish them. */
133
+ export type TornActionReason = 'rival-holds-revision' | 'completion-refused' | 'transforms-not-held';
134
+
135
+ /** Thrown when a write HALF-LANDED and was not finished: its log entry is stored, but at least one
136
+ * of the other blocks that entry names is not known to hold the write.
137
+ *
138
+ * A write's log tail is committed before the rest of its blocks, so a write can be refused AFTER the
139
+ * tail was stored (see `NetworkTransactor.commit`). The writer's retry then finds its own log entry.
140
+ * That entry proves only that the tail landed; the write is saved only once EVERY block the entry
141
+ * names holds the write — at the write's own revision, or at a later revision that was built from
142
+ * it — so the retry finishes the remaining blocks at the same action id and revision (see
143
+ * `Collection.completeOwnEntry`). This error is what it raises when it could not — {@link reason}
144
+ * says why, and {@link final} says what that leaves the caller able to do.
145
+ *
146
+ * **{@link final} is the field to act on.** An application's only safe reaction to a failed write
147
+ * is to submit it again, and that is only safe when the first one can never show up:
148
+ *
149
+ * - `final: true` — the write is NOT saved and never will be. Every block it still needed answered
150
+ * for its whole cohort that it does not hold the write (its history passed the write's revision
151
+ * without it, or it never reached that revision), and the write's pending records were confirmed
152
+ * cancelled first, so nothing is left that could land it. Submitting the same change again stores
153
+ * it once.
154
+ * - `final: false` — that could NOT be established. The write may already be saved, or may still
155
+ * land (a block's cohort did not all answer, members contradicted each other, fewer than a
156
+ * majority hold it, or the cancel could not be confirmed). Submitting again can store the change
157
+ * twice; read the data back first.
158
+ *
159
+ * It is deliberately not a {@link SyncRetryExhaustedError}: that error says the write never landed,
160
+ * whereas here the log already holds an entry for it. Finishing needs the original action id and
161
+ * the transforms it sent, and both are gone once this escapes. Re-driving under a NEW revision
162
+ * records the same actions in the log twice, so nothing here does that on the caller's behalf. The
163
+ * staged actions are left in place on the collection, so at this level "submit again" is calling
164
+ * `sync()` again, and abandoning the write is the caller's to do (`Collection.restorePending`).
165
+ * `Tree.replace` and `Diary.append` do abandon it — they own both the staging and the flush, and a
166
+ * failed call that left its action staged would ride along, unasked, with the caller's next write.
167
+ *
168
+ * Raised out of {@link ICollection.sync} / {@link ICollection.updateAndSync}, and out of the
169
+ * refresh `TransactionCoordinator.commit` runs between attempts (`Collection.refreshInFlight`).
170
+ * The coordinator passes it on bare only when no other participant of the commit is saved; when
171
+ * one is, it arrives as the `reason` of a `CoordinatorPartialCommitError` that names the saved
172
+ * participants. */
173
+ export class TornActionError extends Error {
174
+ constructor(
175
+ readonly collectionId: CollectionId,
176
+ /** The half-landed write's action id. */
177
+ readonly actionId: string,
178
+ /** The revision its log entry landed at — the revision every block it names had to take. */
179
+ readonly rev: number,
180
+ /** The blocks the entry names that are not known to hold the write. */
181
+ readonly blockIds: BlockId[],
182
+ /** Why finishing failed — see {@link TornActionReason}. Says nothing about {@link final}. */
183
+ readonly reason: TornActionReason,
184
+ /** Whether the write is definitely not saved and can never land — see the class comment.
185
+ * `false` means "not established", never "it landed". */
186
+ readonly final: boolean,
187
+ /** The refusal in the responder's own words, for a log line. Never branch on it. */
188
+ readonly detail: string,
189
+ /** The confirmed revision a responder reported holding under ANOTHER action, when the refusal
190
+ * carried one (see `StaleFailure.staleAt`). */
191
+ readonly staleAt?: { blockId: BlockId; rev: number },
192
+ ) {
193
+ super(`collection ${collectionId}: action ${actionId} is torn at rev ${rev} — its log entry is stored `
194
+ + `but block(s) ${blockIds.join(', ') || '(unknown)'} are not known to hold it, and the write cannot be `
195
+ + `finished: ${detail}`
196
+ + (staleAt ? ` (block ${staleAt.blockId} is at rev ${staleAt.rev})` : '')
197
+ + (final
198
+ ? ' — the write is not saved and cannot land; it is safe to submit again'
199
+ : ' — whether the write is saved could not be established; it may be saved already or land later'));
200
+ this.name = 'TornActionError';
201
+ }
202
+ }
203
+
114
204
  /** Thrown when a collection that already holds a committed revision reads its own header
115
205
  * block as authoritatively absent.
116
206
  *
@@ -147,8 +237,13 @@ export interface ICollection<TAction> {
147
237
  readonly id: CollectionId;
148
238
  act(...actions: Action<TAction>[]): Promise<void>;
149
239
  update(): Promise<void>;
150
- sync(options?: SyncOptions): Promise<void>;
151
- updateAndSync(options?: SyncOptions): Promise<void>;
240
+ /** Push staged changes. Resolves to who holds what was committed, or `undefined` when NOTHING WAS
241
+ * WRITTEN — nothing was staged, so there was no pend and no commit and there is no durability to
242
+ * report. A write that never landed throws ({@link SyncRetryExhaustedError}) rather than returning
243
+ * a class. Decide "is this saved" with `isFullyDurable`, never by comparing `quorum`. */
244
+ sync(options?: SyncOptions): Promise<WriteDurability | undefined>;
245
+ /** Refresh, then {@link sync}. Same return contract. */
246
+ updateAndSync(options?: SyncOptions): Promise<WriteDurability | undefined>;
152
247
  selectLog(forward?: boolean): AsyncIterableIterator<Action<TAction>>;
153
248
  }
154
249
 
@@ -1,68 +1,71 @@
1
- import { Collection } from "../../collection/collection.js";
2
- import type { ITransactor, Action, BlockId, BlockStore, IBlock, CollectionInitOptions, CollectionId } from "../../index.js";
3
- import { registerCollectionType } from "../../collection/collection-type-registry.js";
4
- import { DiaryHeaderBlockType } from "./struct.js";
5
-
6
- /** A diary keeps every entry in the log itself, so the header block is the whole structure
7
- * and the "append" handler has no blocks to touch. */
8
- function diaryInit<TEntry>(): CollectionInitOptions<TEntry> {
9
- return {
10
- modules: {
11
- "append": async (_action, _trx) => {
12
- // Append-only diary doesn't need to modify any blocks
13
- // All entries are stored in the log
14
- }
15
- },
16
- createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => ({
17
- header: store.createBlockHeader(DiaryHeaderBlockType, id)
18
- })
19
- };
20
- }
21
-
22
- export class Diary<TEntry> {
23
- private constructor(
24
- private readonly collection: Collection<TEntry>
25
- ) {
26
- }
27
-
28
- /** Open an existing diary, or stage a fresh empty one when nothing has ever been committed
29
- * under this id. Attach-or-create — see {@link Collection.createOrOpen}. */
30
- static async createOrOpen<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry>> {
31
- const collection = await Collection.createOrOpen(network, id, diaryInit<TEntry>());
32
- return new Diary<TEntry>(collection);
33
- }
34
-
35
- /** Open an EXISTING diary, or resolve to `undefined` when no header block has ever been
36
- * committed under this id. Never brings a diary into existence — see {@link Collection.open}. */
37
- static async open<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry> | undefined> {
38
- const collection = await Collection.open(network, id, diaryInit<TEntry>());
39
- return collection ? new Diary<TEntry>(collection) : undefined;
40
- }
41
-
42
- async append(data: TEntry): Promise<void> {
43
- const action: Action<TEntry> = {
44
- type: "append",
45
- data: data
46
- };
47
-
48
- await this.collection.act(action);
49
- await this.collection.updateAndSync();
50
- }
51
-
52
- /** Fetch the latest state from the network */
53
- async update(): Promise<void> {
54
- await this.collection.update();
55
- }
56
-
57
- async *select(forward = true): AsyncIterableIterator<TEntry> {
58
- for await (const entry of this.collection.selectLog(forward)) {
59
- yield entry.data;
60
- }
61
- }
62
- }
63
-
64
- registerCollectionType({
65
- blockType: DiaryHeaderBlockType,
66
- name: "Diary",
67
- createOrOpen: (transactor, id) => Collection.createOrOpen(transactor, id, diaryInit<unknown>()),
68
- });
1
+ import { Collection } from "../../collection/collection.js";
2
+ import type { ITransactor, Action, BlockId, BlockStore, IBlock, CollectionInitOptions, CollectionId, WriteDurability } from "../../index.js";
3
+ import { registerCollectionType } from "../../collection/collection-type-registry.js";
4
+ import { DiaryHeaderBlockType } from "./struct.js";
5
+
6
+ /** A diary keeps every entry in the log itself, so the header block is the whole structure
7
+ * and the "append" handler has no blocks to touch. */
8
+ function diaryInit<TEntry>(): CollectionInitOptions<TEntry> {
9
+ return {
10
+ modules: {
11
+ "append": async (_action, _trx) => {
12
+ // Append-only diary doesn't need to modify any blocks
13
+ // All entries are stored in the log
14
+ }
15
+ },
16
+ createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => ({
17
+ header: store.createBlockHeader(DiaryHeaderBlockType, id)
18
+ })
19
+ };
20
+ }
21
+
22
+ export class Diary<TEntry> {
23
+ private constructor(
24
+ private readonly collection: Collection<TEntry>
25
+ ) {
26
+ }
27
+
28
+ /** Open an existing diary, or stage a fresh empty one when nothing has ever been committed
29
+ * under this id. Attach-or-create — see {@link Collection.createOrOpen}. */
30
+ static async createOrOpen<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry>> {
31
+ const collection = await Collection.createOrOpen(network, id, diaryInit<TEntry>());
32
+ return new Diary<TEntry>(collection);
33
+ }
34
+
35
+ /** Open an EXISTING diary, or resolve to `undefined` when no header block has ever been
36
+ * committed under this id. Never brings a diary into existence — see {@link Collection.open}. */
37
+ static async open<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry> | undefined> {
38
+ const collection = await Collection.open(network, id, diaryInit<TEntry>());
39
+ return collection ? new Diary<TEntry>(collection) : undefined;
40
+ }
41
+
42
+ /** Append one entry and flush it. Forwards the collection's answer verbatim — see
43
+ * {@link Collection.sync} for what `undefined` means. This layer never interprets the value.
44
+ * An append that throws leaves nothing staged (see {@link Collection.actAndSync}), so a caller
45
+ * that appends it again records it once, not twice. */
46
+ async append(data: TEntry): Promise<WriteDurability | undefined> {
47
+ const action: Action<TEntry> = {
48
+ type: "append",
49
+ data: data
50
+ };
51
+
52
+ return await this.collection.actAndSync([action]);
53
+ }
54
+
55
+ /** Fetch the latest state from the network */
56
+ async update(): Promise<void> {
57
+ await this.collection.update();
58
+ }
59
+
60
+ async *select(forward = true): AsyncIterableIterator<TEntry> {
61
+ for await (const entry of this.collection.selectLog(forward)) {
62
+ yield entry.data;
63
+ }
64
+ }
65
+ }
66
+
67
+ registerCollectionType({
68
+ blockType: DiaryHeaderBlockType,
69
+ name: "Diary",
70
+ createOrOpen: (transactor, id) => Collection.createOrOpen(transactor, id, diaryInit<unknown>()),
71
+ });
@@ -26,7 +26,10 @@ export const rootId$ = nameof<TreeCollectionHeaderBlock>("rootId");
26
26
  *
27
27
  * - `absent` — the key must not exist (SQL INSERT): a hit throws {@link TreeKeyTakenError},
28
28
  * discarding the whole action's staged writes.
29
- * - `keepExisting` — if the key exists, skip this entry silently (SQL INSERT OR IGNORE).
29
+ * - `keepExisting` — if the key exists, skip this entry silently. No SQL statement stages
30
+ * it any more (INSERT OR IGNORE is guarded `absent`, because skipping only the main-table
31
+ * entry would leave the statement's index entries beside a rival's row — see the
32
+ * `unchanged` paragraph in docs/internals.md); kept as a serialized log form.
30
33
  * - `absentRange` — no entry OTHER THAN this action's own key may exist in `range`
31
34
  * (secondary-UNIQUE enforcement, where uniqueness is a property of a framed key
32
35
  * PREFIX rather than one exact key: an index tree keys `indexKey ‖ primaryKey`, so
@@ -34,47 +37,125 @@ export const rootId$ = nameof<TreeCollectionHeaderBlock>("rootId");
34
37
  * foreign hit throws {@link TreeRangeTakenError}. The entry being staged lands inside
35
38
  * its own range, so the scan excludes its exact key — otherwise every guarded
36
39
  * re-stage of a present key (a replay after a clean refresh) would refuse itself.
40
+ * - `unchanged` — see {@link TreeUnchangedGuard}: the only kind that says something must
41
+ * still BE here, rather than that nothing may be.
37
42
  *
38
43
  * MIXED VERSIONS: a peer running a build that predates guards destructures `[key, entry]`
39
44
  * and ignores the third slot — its replays revert to today's silent overwrite. No version
40
45
  * gating exists yet (see backlog ticket `debt-mixed-version-identify-incompatibility`).
41
46
  */
42
- export type TreeEntryGuard<TKey> =
47
+ export type TreeEntryGuard<TKey, TEntry = unknown> =
43
48
  | { kind: 'absent' }
44
49
  | { kind: 'keepExisting' }
45
- | { kind: 'absentRange', range: KeyRange<TKey> };
50
+ | { kind: 'absentRange', range: KeyRange<TKey> }
51
+ | TreeUnchangedGuard<TEntry>;
46
52
 
47
- /** Represents a unit of change to a tree collection. */
48
- export type TreeReplaceAction<TKey, TEntry> = [
49
- // The key to replace
53
+ /**
54
+ * "The entry I am replacing must still be exactly the one I read" — the optimistic-concurrency
55
+ * (lost-update) guard, and the only {@link TreeEntryGuard} kind that also applies to a DELETE.
56
+ * The entry at the key must be PRESENT and structurally equal to `expected` (`structuralEquals`
57
+ * in `packages/db-core/src/utility/structural-equals.ts`); otherwise the handler throws
58
+ * {@link TreeEntryChangedError}.
59
+ *
60
+ * ABSENT COUNTS AS CHANGED. The staged effect was computed from an entry image that no longer
61
+ * exists, so re-applying it is never right: an upsert would resurrect a row a rival deleted, and
62
+ * a delete would be the second of two racing deletes. Refusing both is the optimistic answer —
63
+ * the writer read a row that is now gone, and an application-level retry then affects zero rows
64
+ * sequentially. If tolerating absence on deletes is ever genuinely wanted, add a separate
65
+ * `unchangedOrAbsent` kind rather than weakening this one's contract.
66
+ *
67
+ * `expected` is the entry AS STORED, not a digest: a guard rides in the log beside its action
68
+ * under the same encoding entries already use, so a full copy round-trips by construction and no
69
+ * digest scheme has to be invented, versioned, or kept in sync with the entry encoding.
70
+ *
71
+ * NOTE: that choice roughly doubles the row bytes of every guarded log entry (the new entry plus
72
+ * a full copy of the old one). If log volume ever becomes the binding constraint, replace
73
+ * `expected` with a content digest — the block-digest utilities under
74
+ * `packages/db-core/src/transaction/` are the starting point — and accept the versioning burden
75
+ * that comes with it.
76
+ */
77
+ export type TreeUnchangedGuard<TEntry = unknown> = {
78
+ kind: 'unchanged',
79
+ /** The entry image the staged write read, as stored. */
80
+ expected: TEntry,
81
+ };
82
+
83
+ /** An element that writes (inserts or replaces) an entry. Any {@link TreeEntryGuard} kind is
84
+ * meaningful here. */
85
+ export type TreeUpsertElement<TKey, TEntry> = [
86
+ // The key to write at
87
+ key: TKey,
88
+ // The entry to write
89
+ entry: TEntry,
90
+ // Optional intent, re-checked on every handler run (see TreeEntryGuard). Absent = plain
91
+ // upsert, so existing callers and previously committed log entries deserialize and replay
92
+ // unchanged.
93
+ guard?: TreeEntryGuard<TKey, TEntry>,
94
+ ];
95
+
96
+ /** An element that deletes the entry at a key. Only {@link TreeUnchangedGuard} says anything a
97
+ * delete can act on — the other kinds all assert that nothing is present, which would make the
98
+ * delete a no-op by construction. The handler rejects any other kind at runtime too, since a
99
+ * replayed log entry is deserialized data that this type never policed. */
100
+ export type TreeDeleteElement<TKey, TEntry> = [
101
+ // The key to delete
50
102
  key: TKey,
51
- // The new entry to replace the old entry with (if not provided, the key is deleted)
52
- entry?: TEntry,
53
- // Optional uniqueness intent, re-checked on every handler run (see TreeEntryGuard).
54
- // Absent = plain upsert, so existing callers and previously committed log entries
55
- // deserialize and replay unchanged.
56
- guard?: TreeEntryGuard<TKey>,
57
- ][];
103
+ // Always empty: the absent entry is what marks this element a delete
104
+ entry?: undefined,
105
+ // Optional lost-update guard: refuse the delete unless the entry is still the one that was read
106
+ guard?: TreeUnchangedGuard<TEntry>,
107
+ ];
108
+
109
+ /** Represents a unit of change to a tree collection. */
110
+ export type TreeReplaceAction<TKey, TEntry> = (
111
+ | TreeUpsertElement<TKey, TEntry>
112
+ | TreeDeleteElement<TKey, TEntry>
113
+ )[];
58
114
 
59
115
  /**
60
- * Thrown by the tree `replace` handler when an entry guarded `absent` finds its key
61
- * already present — at initial staging, or (the load-bearing case) at conflict replay
62
- * after a rival writer's commit was adopted. The throw discards the whole action's
63
- * staged writes (the handler runs inside an all-or-nothing Atomic wrapper) and
64
- * propagates out of the sync/commit retry loops: it is not a StaleFailure, so no
65
- * retry absorbs it, and it must never be downgraded to a retryable condition.
116
+ * Base of every refusal a {@link TreeEntryGuard} raises out of the tree `replace` handler at
117
+ * initial staging, or (the load-bearing case) at conflict replay after a rival writer's commit
118
+ * was adopted. One contract, stated once for every subclass:
119
+ *
120
+ * - The throw **discards the whole action's staged writes** the handler runs inside an
121
+ * all-or-nothing Atomic wrapper, so not one entry of a multi-entry action lands.
122
+ * - It is **not a `StaleFailure`**, so neither `Collection.sync`'s retry loop nor
123
+ * `TransactionCoordinator.commit`'s stale-loss re-drive absorbs it; it propagates out of the
124
+ * losing commit, and it must never be downgraded to a retryable condition.
125
+ *
126
+ * Subclasses split by WHAT was refused, because consumers map them differently: a
127
+ * {@link TreeKeyTakenError} is a uniqueness violation (the Quereus bridge renders it as
128
+ * `UNIQUE constraint failed`), while a {@link TreeEntryChangedError} is a lost update. Catch
129
+ * this base to treat any guard refusal uniformly; catch a subclass to say which happened.
66
130
  */
67
- export class TreeKeyTakenError<TKey = unknown> extends Error {
131
+ export class TreeGuardRefusedError<TKey = unknown> extends Error {
68
132
  constructor(
69
133
  /** The collection whose tree refused the entry. */
70
134
  public readonly collectionId: CollectionId,
135
+ /** The key whose staged entry was refused. */
136
+ public readonly key: TKey,
137
+ message: string,
138
+ ) {
139
+ super(message);
140
+ this.name = 'TreeGuardRefusedError';
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Thrown when an entry guarded `absent` finds its key already present: a duplicate-key
146
+ * refusal. See {@link TreeGuardRefusedError} for the contract every guard refusal shares.
147
+ */
148
+ export class TreeKeyTakenError<TKey = unknown> extends TreeGuardRefusedError<TKey> {
149
+ constructor(
150
+ collectionId: CollectionId,
71
151
  /** The key some other writer already committed (for the `absentRange` subclass: the
72
152
  * key this action was staging, whose claimed range a rival occupies). */
73
- public readonly key: TKey,
153
+ key: TKey,
74
154
  /** Subclass override of the rendered message; the default names the exact-key refusal. */
75
155
  message?: string,
76
156
  ) {
77
- super(message ?? `Tree collection ${collectionId}: key ${renderKey(key)} is already taken by a committed entry`);
157
+ super(collectionId, key,
158
+ message ?? `Tree collection ${collectionId}: key ${renderKey(key)} is already taken by a committed entry`);
78
159
  this.name = 'TreeKeyTakenError';
79
160
  }
80
161
  }
@@ -107,9 +188,60 @@ export class TreeRangeTakenError<TKey = unknown> extends TreeKeyTakenError<TKey>
107
188
  }
108
189
  }
109
190
 
191
+ /**
192
+ * The {@link TreeUnchangedGuard} refusal: the entry at `key` is no longer the one the staged
193
+ * write read — a rival committed a different entry there, or removed it (`actual` is
194
+ * `undefined`). A lost update, NOT a uniqueness violation, which is why this deliberately does
195
+ * NOT subclass {@link TreeKeyTakenError}: the Quereus bridge renders every `TreeKeyTakenError`
196
+ * as that collection's registered `UNIQUE constraint failed` message (`mapCommitRefusal` in
197
+ * `packages/quereus-plugin-optimystic/src/optimystic-adapter/txn-bridge.ts`), and reporting a
198
+ * concurrent-update refusal as a uniqueness failure would mislead every client that reads it.
199
+ * The shared contract lives on {@link TreeGuardRefusedError}.
200
+ */
201
+ export class TreeEntryChangedError<TKey = unknown, TEntry = unknown> extends TreeGuardRefusedError<TKey> {
202
+ constructor(
203
+ collectionId: CollectionId,
204
+ /** The key whose entry the staged write was replacing or deleting. */
205
+ key: TKey,
206
+ /** The entry image the staged write read, as carried by its guard. */
207
+ public readonly expected: TEntry,
208
+ /** What is committed at `key` now; `undefined` when the entry is gone entirely. */
209
+ public readonly actual: TEntry | undefined,
210
+ ) {
211
+ super(collectionId, key,
212
+ `Tree collection ${collectionId}: the entry at key ${renderKey(key)} was `
213
+ + `${actual === undefined ? 'removed' : 'changed'} by another writer since this change read it`);
214
+ this.name = 'TreeEntryChangedError';
215
+ }
216
+ }
217
+
218
+ /**
219
+ * A DELETE element carries a guard kind no delete can act on. NOT a concurrency refusal and
220
+ * deliberately not a {@link TreeGuardRefusedError}: every other kind asserts that nothing is
221
+ * present, which would make the delete a no-op by construction, so this is a malformed action —
222
+ * a caller bug, or a log entry written by a build whose element types disagree with this one.
223
+ * The handler raises it rather than ignoring the guard (the pre-guard behaviour) so a writer
224
+ * never believes an unenforceable guard is being enforced.
225
+ */
226
+ export class TreeDeleteGuardKindError<TKey = unknown> extends Error {
227
+ constructor(
228
+ /** The collection whose tree rejected the element. */
229
+ public readonly collectionId: CollectionId,
230
+ /** The key the malformed delete named. */
231
+ public readonly key: TKey,
232
+ /** The `kind` that was carried; only `'unchanged'` is meaningful on a delete. */
233
+ public readonly guardKind: string,
234
+ ) {
235
+ super(`Tree collection ${collectionId}: the delete of key ${renderKey(key)} carries a `
236
+ + `'${guardKind}' guard; only 'unchanged' is meaningful on a delete`);
237
+ this.name = 'TreeDeleteGuardKindError';
238
+ }
239
+ }
240
+
110
241
  /** String keys render JSON-quoted so framing control bytes stay visible/escaped in logs;
111
242
  * everything else via String() — JSON.stringify would throw on a bigint key, and an error
112
- * constructor must never be the second failure. */
243
+ * constructor must never be the second failure. Module-private on purpose: every tree message
244
+ * renders a key the same way because every one of them is constructed in this file. */
113
245
  function renderKey(key: unknown): string {
114
246
  return typeof key === 'string' ? JSON.stringify(key) : String(key);
115
247
  }