@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.
- package/dist/src/cluster/structs.d.ts +30 -6
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +5 -3
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
- package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/codec.js +8 -2
- package/dist/src/cohort-topic/wire/codec.js.map +1 -1
- package/dist/src/collection/collection.d.ts +439 -34
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +843 -148
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +109 -4
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +88 -1
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/diary/diary.d.ts +6 -2
- package/dist/src/collections/diary/diary.d.ts.map +1 -1
- package/dist/src/collections/diary/diary.js +5 -2
- package/dist/src/collections/diary/diary.js.map +1 -1
- package/dist/src/collections/tree/struct.d.ts +124 -16
- package/dist/src/collections/tree/struct.d.ts.map +1 -1
- package/dist/src/collections/tree/struct.js +86 -11
- package/dist/src/collections/tree/struct.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +13 -3
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +106 -40
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +8 -2
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/durability.d.ts +45 -0
- package/dist/src/network/durability.d.ts.map +1 -0
- package/dist/src/network/durability.js +114 -0
- package/dist/src/network/durability.js.map +1 -0
- package/dist/src/network/i-key-network.d.ts +14 -6
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/index.d.ts +3 -0
- package/dist/src/network/index.d.ts.map +1 -1
- package/dist/src/network/index.js +3 -0
- package/dist/src/network/index.js.map +1 -1
- package/dist/src/network/lineage.d.ts +39 -0
- package/dist/src/network/lineage.d.ts.map +1 -0
- package/dist/src/network/lineage.js +61 -0
- package/dist/src/network/lineage.js.map +1 -0
- package/dist/src/network/routing-key.d.ts +24 -0
- package/dist/src/network/routing-key.d.ts.map +1 -0
- package/dist/src/network/routing-key.js +9 -0
- package/dist/src/network/routing-key.js.map +1 -0
- package/dist/src/network/stale-failure.d.ts +5 -1
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +5 -1
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +151 -8
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/reactivity/wire.d.ts.map +1 -1
- package/dist/src/reactivity/wire.js +8 -2
- package/dist/src/reactivity/wire.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +64 -7
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +150 -23
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +141 -21
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +408 -122
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +39 -10
- package/dist/src/transaction/errors.d.ts.map +1 -1
- package/dist/src/transaction/errors.js +34 -9
- package/dist/src/transaction/errors.js.map +1 -1
- package/dist/src/transaction/index.d.ts +1 -0
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transactor/block-floors.d.ts +78 -0
- package/dist/src/transactor/block-floors.d.ts.map +1 -0
- package/dist/src/transactor/block-floors.js +62 -0
- package/dist/src/transactor/block-floors.js.map +1 -0
- package/dist/src/transactor/change-notifier.d.ts +43 -0
- package/dist/src/transactor/change-notifier.d.ts.map +1 -1
- package/dist/src/transactor/change-notifier.js +3 -0
- package/dist/src/transactor/change-notifier.js.map +1 -1
- package/dist/src/transactor/index.d.ts +1 -0
- package/dist/src/transactor/index.d.ts.map +1 -1
- package/dist/src/transactor/index.js +1 -0
- package/dist/src/transactor/index.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +43 -6
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +279 -86
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +94 -4
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +120 -42
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transactor/transactor.d.ts +14 -1
- package/dist/src/transactor/transactor.d.ts.map +1 -1
- package/dist/src/transform/atomic.d.ts.map +1 -1
- package/dist/src/transform/atomic.js +12 -9
- package/dist/src/transform/atomic.js.map +1 -1
- package/dist/src/transform/base-pins.d.ts +50 -17
- package/dist/src/transform/base-pins.d.ts.map +1 -1
- package/dist/src/transform/base-pins.js +58 -11
- package/dist/src/transform/base-pins.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +75 -11
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +162 -36
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +8 -1
- package/dist/src/transform/digest.d.ts.map +1 -1
- package/dist/src/transform/digest.js +19 -9
- package/dist/src/transform/digest.js.map +1 -1
- package/dist/src/transform/helpers.d.ts +6 -2
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +8 -3
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +94 -14
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +222 -68
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/structural-equals.d.ts +38 -0
- package/dist/src/utility/structural-equals.d.ts.map +1 -0
- package/dist/src/utility/structural-equals.js +77 -0
- package/dist/src/utility/structural-equals.js.map +1 -0
- package/package.json +2 -2
- package/src/cluster/structs.ts +28 -7
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
- package/src/cohort-topic/wire/codec.ts +222 -216
- package/src/collection/collection.ts +960 -157
- package/src/collection/struct.ts +99 -4
- package/src/collections/diary/diary.ts +71 -68
- package/src/collections/tree/struct.ts +155 -23
- package/src/collections/tree/tree.ts +127 -40
- package/src/index.ts +1 -1
- package/src/matchmaking/wire.ts +8 -2
- package/src/network/durability.ts +123 -0
- package/src/network/i-key-network.ts +60 -52
- package/src/network/index.ts +10 -7
- package/src/network/lineage.ts +80 -0
- package/src/network/routing-key.ts +27 -0
- package/src/network/stale-failure.ts +5 -1
- package/src/network/struct.ts +156 -8
- package/src/reactivity/wire.ts +230 -224
- package/src/testing/test-transactor.ts +152 -24
- package/src/transaction/coordinator.ts +470 -131
- package/src/transaction/errors.ts +34 -9
- package/src/transaction/index.ts +1 -0
- package/src/transactor/block-floors.ts +105 -0
- package/src/transactor/change-notifier.ts +129 -80
- package/src/transactor/index.ts +1 -0
- package/src/transactor/network-transactor.ts +297 -89
- package/src/transactor/transactor-source.ts +131 -42
- package/src/transactor/transactor.ts +13 -1
- package/src/transform/atomic.ts +25 -22
- package/src/transform/base-pins.ts +76 -18
- package/src/transform/cache-source.ts +327 -194
- package/src/transform/digest.ts +21 -10
- package/src/transform/helpers.ts +8 -3
- package/src/transform/tracker.ts +423 -270
- package/src/utility/structural-equals.ts +72 -0
- package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
- package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
- package/dist/src/utility/block-id-to-bytes.js +0 -7
- package/dist/src/utility/block-id-to-bytes.js.map +0 -1
- package/src/utility/block-id-to-bytes.ts +0 -8
package/src/collection/struct.ts
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
52
|
-
entry?:
|
|
53
|
-
// Optional
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
}
|