@optimystic/db-core 1.0.0-beta.1 → 1.0.0-beta.3
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 +45 -15
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/collection.d.ts +29 -2
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +37 -2
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/struct.d.ts +81 -2
- package/dist/src/collections/tree/struct.d.ts.map +1 -1
- package/dist/src/collections/tree/struct.js +59 -0
- package/dist/src/collections/tree/struct.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +39 -2
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/logger-registry.d.ts +57 -0
- package/dist/src/logger-registry.d.ts.map +1 -0
- package/dist/src/logger-registry.js +168 -0
- package/dist/src/logger-registry.js.map +1 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +3 -0
- package/dist/src/logger.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +8 -3
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/package.json +1 -1
- package/src/cluster/structs.ts +277 -247
- package/src/collection/collection.ts +37 -2
- package/src/collections/tree/struct.ts +116 -26
- package/src/collections/tree/tree.ts +36 -2
- package/src/index.ts +1 -0
- package/src/logger-registry.ts +224 -0
- package/src/logger.ts +4 -0
- package/src/transactor/network-transactor.ts +8 -3
|
@@ -1,26 +1,116 @@
|
|
|
1
|
-
import type { BlockId, CollectionHeaderBlock } from "../../index.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
import type { BlockId, CollectionHeaderBlock, CollectionId } from "../../index.js";
|
|
2
|
+
import type { KeyRange } from "../../btree/index.js";
|
|
3
|
+
import { registerBlockType } from "../../blocks/block-types.js";
|
|
4
|
+
import { registerCollectionType } from "../../collection/collection-type-registry.js";
|
|
5
|
+
import { nameof } from "../../utility/nameof.js";
|
|
6
|
+
|
|
7
|
+
export const TreeHeaderBlockType = registerBlockType("TRE", "TreeHeaderBlock");
|
|
8
|
+
|
|
9
|
+
registerCollectionType({
|
|
10
|
+
blockType: TreeHeaderBlockType,
|
|
11
|
+
name: "Tree",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type TreeCollectionHeaderBlock = CollectionHeaderBlock & {
|
|
15
|
+
rootId: BlockId;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const rootId$ = nameof<TreeCollectionHeaderBlock>("rootId");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serializable statement of the INTENT behind a staged tree entry, enforced by the
|
|
22
|
+
* `replace` handler every time it runs — at initial staging AND at every conflict
|
|
23
|
+
* replay against a newly adopted committed revision. Without a guard, staging is a
|
|
24
|
+
* bare upsert, and a conflict replay silently overwrites whatever a rival writer
|
|
25
|
+
* committed at the same key (the concurrent-INSERT lost-uniqueness bug).
|
|
26
|
+
*
|
|
27
|
+
* - `absent` — the key must not exist (SQL INSERT): a hit throws {@link TreeKeyTakenError},
|
|
28
|
+
* discarding the whole action's staged writes.
|
|
29
|
+
* - `keepExisting` — if the key exists, skip this entry silently (SQL INSERT OR IGNORE).
|
|
30
|
+
* - `absentRange` — no entry OTHER THAN this action's own key may exist in `range`
|
|
31
|
+
* (secondary-UNIQUE enforcement, where uniqueness is a property of a framed key
|
|
32
|
+
* PREFIX rather than one exact key: an index tree keys `indexKey ‖ primaryKey`, so
|
|
33
|
+
* two rows sharing a unique value sit at different keys inside one prefix range). A
|
|
34
|
+
* foreign hit throws {@link TreeRangeTakenError}. The entry being staged lands inside
|
|
35
|
+
* its own range, so the scan excludes its exact key — otherwise every guarded
|
|
36
|
+
* re-stage of a present key (a replay after a clean refresh) would refuse itself.
|
|
37
|
+
*
|
|
38
|
+
* MIXED VERSIONS: a peer running a build that predates guards destructures `[key, entry]`
|
|
39
|
+
* and ignores the third slot — its replays revert to today's silent overwrite. No version
|
|
40
|
+
* gating exists yet (see backlog ticket `debt-mixed-version-identify-incompatibility`).
|
|
41
|
+
*/
|
|
42
|
+
export type TreeEntryGuard<TKey> =
|
|
43
|
+
| { kind: 'absent' }
|
|
44
|
+
| { kind: 'keepExisting' }
|
|
45
|
+
| { kind: 'absentRange', range: KeyRange<TKey> };
|
|
46
|
+
|
|
47
|
+
/** Represents a unit of change to a tree collection. */
|
|
48
|
+
export type TreeReplaceAction<TKey, TEntry> = [
|
|
49
|
+
// The key to replace
|
|
50
|
+
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
|
+
][];
|
|
58
|
+
|
|
59
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
export class TreeKeyTakenError<TKey = unknown> extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
/** The collection whose tree refused the entry. */
|
|
70
|
+
public readonly collectionId: CollectionId,
|
|
71
|
+
/** The key some other writer already committed (for the `absentRange` subclass: the
|
|
72
|
+
* key this action was staging, whose claimed range a rival occupies). */
|
|
73
|
+
public readonly key: TKey,
|
|
74
|
+
/** Subclass override of the rendered message; the default names the exact-key refusal. */
|
|
75
|
+
message?: string,
|
|
76
|
+
) {
|
|
77
|
+
super(message ?? `Tree collection ${collectionId}: key ${renderKey(key)} is already taken by a committed entry`);
|
|
78
|
+
this.name = 'TreeKeyTakenError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `absentRange` refusal: the guarded entry's key is free, but some OTHER committed
|
|
84
|
+
* entry (`occupant`) sits inside the range the entry claims exclusively — for a unique
|
|
85
|
+
* index tree, a rival's row carrying the same unique value under a different primary
|
|
86
|
+
* key. A subclass of {@link TreeKeyTakenError} on purpose: every consumer that treats a
|
|
87
|
+
* key refusal as a non-retryable uniqueness failure (the sync/commit retry loops let it
|
|
88
|
+
* escape; the Quereus bridge maps it by `collectionId` to a `UNIQUE constraint failed`
|
|
89
|
+
* message) handles this one identically without a second arm. `collectionId` is the
|
|
90
|
+
* discriminator that names WHICH constraint fired — each unique index is its own
|
|
91
|
+
* collection — so the bridge needs nothing beyond it.
|
|
92
|
+
*/
|
|
93
|
+
export class TreeRangeTakenError<TKey = unknown> extends TreeKeyTakenError<TKey> {
|
|
94
|
+
constructor(
|
|
95
|
+
collectionId: CollectionId,
|
|
96
|
+
/** The key this action was staging (free — it is the range that is contested). */
|
|
97
|
+
key: TKey,
|
|
98
|
+
/** The range the entry claimed exclusively. */
|
|
99
|
+
public readonly range: KeyRange<TKey>,
|
|
100
|
+
/** The committed key found inside `range` that is not `key`. */
|
|
101
|
+
public readonly occupant: TKey,
|
|
102
|
+
) {
|
|
103
|
+
super(collectionId, key,
|
|
104
|
+
`Tree collection ${collectionId}: key ${renderKey(key)} is guarded unique over a key range `
|
|
105
|
+
+ `already occupied by committed entry ${renderKey(occupant)}`);
|
|
106
|
+
this.name = 'TreeRangeTakenError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** String keys render JSON-quoted so framing control bytes stay visible/escaped in logs;
|
|
111
|
+
* everything else via String() — JSON.stringify would throw on a bigint key, and an error
|
|
112
|
+
* constructor must never be the second failure. */
|
|
113
|
+
function renderKey(key: unknown): string {
|
|
114
|
+
return typeof key === 'string' ? JSON.stringify(key) : String(key);
|
|
115
|
+
}
|
|
116
|
+
|
|
@@ -2,7 +2,7 @@ import { Collection, type CollectionInitOptions, type CollectionId, type Collect
|
|
|
2
2
|
import type { ITransactor, BlockId, BlockStore, IBlock, ActionId } from "../../index.js";
|
|
3
3
|
import { BTree, type Path, type KeyRange } from "../../btree/index.js";
|
|
4
4
|
import { CollectionTrunk } from "./collection-trunk.js";
|
|
5
|
-
import { TreeHeaderBlockType, type TreeReplaceAction } from "./struct.js";
|
|
5
|
+
import { TreeHeaderBlockType, TreeKeyTakenError, TreeRangeTakenError, type TreeReplaceAction } from "./struct.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Read-only surface of a tree: every navigation/lookup method a reader needs, with
|
|
@@ -105,8 +105,42 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
|
|
|
105
105
|
compare,
|
|
106
106
|
nodeCapacity, // keep the write btree's fan-out in lock-step with the read btree
|
|
107
107
|
);
|
|
108
|
-
for (const [key, entry] of actions) {
|
|
108
|
+
for (const [key, entry, guard] of actions) {
|
|
109
109
|
if (entry) {
|
|
110
|
+
// Enforce the entry's guard (if any) on EVERY handler run — initial staging
|
|
111
|
+
// and every conflict replay — so the uniqueness decision is re-made against
|
|
112
|
+
// the newest adopted committed state, not just the stage-time snapshot.
|
|
113
|
+
// A throw here discards the whole action's staged writes (Atomic wrapper).
|
|
114
|
+
if (guard !== undefined) {
|
|
115
|
+
if (guard.kind === 'absentRange') {
|
|
116
|
+
// Secondary-UNIQUE: the claimed range (a unique index's framed
|
|
117
|
+
// value prefix) must hold no entry other than this action's own
|
|
118
|
+
// key. Two short descents (range start and end) plus an early-exit
|
|
119
|
+
// walk, over the SAME store the upsert below writes to, so it sees entries earlier
|
|
120
|
+
// actions of this replay already staged or deleted (an UPDATE's
|
|
121
|
+
// delete-old half runs before its guarded insert half). The guard
|
|
122
|
+
// is plain data (it is serialized into the log with its action), and
|
|
123
|
+
// BTree.range reads the range's fields only — so a KeyRange that has
|
|
124
|
+
// round-tripped through JSON is scanned exactly like a live instance.
|
|
125
|
+
for await (const path of actionTree.range(guard.range)) {
|
|
126
|
+
const occupant = actionTree.at(path);
|
|
127
|
+
if (occupant === undefined) continue;
|
|
128
|
+
const occupantKey = keyFromEntry(occupant);
|
|
129
|
+
if (compare(occupantKey, key) === 0) continue; // self-exclusion
|
|
130
|
+
throw new TreeRangeTakenError(id, key, guard.range, occupantKey);
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
const found = await actionTree.find(key);
|
|
134
|
+
if (found.on) {
|
|
135
|
+
if (guard.kind === 'absent') {
|
|
136
|
+
throw new TreeKeyTakenError(id, key);
|
|
137
|
+
}
|
|
138
|
+
// keepExisting: leave the present (rival's) entry in place and
|
|
139
|
+
// skip this entry silently — the INSERT OR IGNORE disposition.
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
110
144
|
await actionTree.upsert(entry);
|
|
111
145
|
} else {
|
|
112
146
|
await actionTree.deleteAt((await actionTree.find(key)));
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./cohort-topic/index.js";
|
|
|
6
6
|
export * from "./collection/index.js";
|
|
7
7
|
export * from "./collections/index.js";
|
|
8
8
|
export * from "./log/index.js";
|
|
9
|
+
export * from "./logger-registry.js";
|
|
9
10
|
export * from "./matchmaking/index.js";
|
|
10
11
|
export * from "./network/index.js";
|
|
11
12
|
export * from "./reactivity/index.js";
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One switch for every `optimystic:*` log channel, whichever copy of `debug` it lives on.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists. `debug` turns namespaces on only from `process.env.DEBUG` (Node) or
|
|
5
|
+
* `localStorage.debug` (its browser build, which is the one Metro bundles). React Native has
|
|
6
|
+
* neither, so every channel is silently off there — and an empty capture reads exactly like "that
|
|
7
|
+
* code never ran". Calling `debug.enable(...)` from app code does not fix it reliably either: it
|
|
8
|
+
* reaches only the copy of `debug` the caller resolved, and each Optimystic package may resolve its
|
|
9
|
+
* own (this repo's install, `nmHoistingLimits: workspaces`, gives every package a separate copy).
|
|
10
|
+
*
|
|
11
|
+
* So each package's `src/logger.ts` registers the copy IT imported, and `enableOptimysticLogging`
|
|
12
|
+
* drives all of them. Coverage follows from every package registering, not from how the install
|
|
13
|
+
* happened to be laid out. `docs/debugging.md` § "Turning logging on" is the user-facing statement.
|
|
14
|
+
*
|
|
15
|
+
* This module imports nothing. It sits below every logger in the package, and
|
|
16
|
+
* `test/barrel-import-cycle.spec.ts` holds registry modules to zero runtime imports.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The slice of a `debug` module this registry drives. Structural, so db-core never type-imports a particular copy. */
|
|
20
|
+
export interface DebugModule {
|
|
21
|
+
enable(namespaces: string): void;
|
|
22
|
+
/** `debug` 4.x returns the namespaces that were active. */
|
|
23
|
+
disable(): string;
|
|
24
|
+
log: (...args: any[]) => any;
|
|
25
|
+
/**
|
|
26
|
+
* `debug`'s persistence hook, which `enable` calls with every new set: it writes
|
|
27
|
+
* `process.env.DEBUG` on Node and `localStorage.debug` in the browser build. Suppressed around
|
|
28
|
+
* every call this registry makes — see `withoutPersisting`. Optional so a test double need not
|
|
29
|
+
* supply one, and because `@types/debug` does not declare it.
|
|
30
|
+
*/
|
|
31
|
+
save?: (namespaces: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where log lines go — the same shape as `debug`'s own `log` property. */
|
|
35
|
+
export type LogSink = (...args: unknown[]) => void;
|
|
36
|
+
|
|
37
|
+
export interface OptimysticLoggingOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Where log lines go. Default: leave each copy's own sink (stderr on Node; `console.debug` in the
|
|
40
|
+
* browser/RN build). Pass one when the platform hides that sink (e.g. a device log filtered above
|
|
41
|
+
* debug level) or to collect a capture in memory.
|
|
42
|
+
*/
|
|
43
|
+
log?: LogSink;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface OptimysticLoggingReport {
|
|
47
|
+
/** The Optimystic contribution, comma-joined — empty when the call enabled nothing. */
|
|
48
|
+
namespaces: string;
|
|
49
|
+
/** One entry per distinct `debug` copy: the packages that registered it. */
|
|
50
|
+
copies: string[][];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Entry {
|
|
54
|
+
module: DebugModule;
|
|
55
|
+
owners: string[];
|
|
56
|
+
/** What this copy had enabled, and its sink, before our first touch. Unset while untouched. */
|
|
57
|
+
original?: { namespaces: string; log: DebugModule['log'] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Pending {
|
|
61
|
+
namespaces: string;
|
|
62
|
+
log?: LogSink;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* NOTE: the state lives on `globalThis`, not in module scope, so a bundle that ends up with two
|
|
67
|
+
* copies of db-core still has ONE registry — a copy of a package that registered with the other
|
|
68
|
+
* db-core would otherwise be unreachable. The cost: two DIFFERENT db-core versions in one bundle
|
|
69
|
+
* share this object, so change its shape only additively.
|
|
70
|
+
*/
|
|
71
|
+
const REGISTRY_KEY = Symbol.for('@optimystic/logger-registry');
|
|
72
|
+
|
|
73
|
+
interface RegistryState {
|
|
74
|
+
entries: Entry[];
|
|
75
|
+
/** The last enable call's settings, applied to copies that register afterwards. Unset when off. */
|
|
76
|
+
pending?: Pending;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function registry(): RegistryState {
|
|
80
|
+
const holder = globalThis as unknown as Record<symbol, RegistryState | undefined>;
|
|
81
|
+
return holder[REGISTRY_KEY] ??= { entries: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `fn` with the copy's `save` hook stubbed out, so what we enable changes this process only.
|
|
86
|
+
*
|
|
87
|
+
* Without this, on Node every `enable` would write `process.env.DEBUG` — and a copy of `debug` that
|
|
88
|
+
* loads LATER reads that variable as its own starting set, so it would arrive with our namespaces
|
|
89
|
+
* baked into what `disableOptimysticLogging` later treats as its baseline (they could never be
|
|
90
|
+
* turned off on that copy). Capturing a baseline via `disable()` would also delete the user's
|
|
91
|
+
* `DEBUG`. In a browser, it would persist our namespaces into `localStorage.debug`, leaving logging
|
|
92
|
+
* on after a reload even without this call.
|
|
93
|
+
*/
|
|
94
|
+
function withoutPersisting(module: DebugModule, fn: () => void): void {
|
|
95
|
+
const save = module.save;
|
|
96
|
+
if (save) module.save = () => { };
|
|
97
|
+
try {
|
|
98
|
+
fn();
|
|
99
|
+
} finally {
|
|
100
|
+
if (save) module.save = save;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bring one copy in line with `pending`: its baseline (captured on first touch) plus ours, and
|
|
106
|
+
* `pending.log` or its original sink.
|
|
107
|
+
*
|
|
108
|
+
* NOTE: ours is appended, so a `-optimystic:…` skip already in the copy's baseline (say
|
|
109
|
+
* `DEBUG='*,-optimystic:*'`) still wins — `debug` checks skips first. That is the user's explicit
|
|
110
|
+
* exclusion, so it is left alone; if it ever confuses someone, have the confirmation line name the
|
|
111
|
+
* conflicting skip rather than overriding it.
|
|
112
|
+
*
|
|
113
|
+
* NOTE: the baseline is captured once per enable/disable cycle, so an app that calls
|
|
114
|
+
* `debug.enable(...)` on a shared copy while ours are on has that change overwritten by our next
|
|
115
|
+
* enable or disable. Fine while apps set their channels once at start-up; if one ever toggles them
|
|
116
|
+
* at run time, re-derive the baseline from the copy's live set minus our contribution instead.
|
|
117
|
+
*/
|
|
118
|
+
function apply(entry: Entry, pending: Pending): void {
|
|
119
|
+
const module = entry.module;
|
|
120
|
+
if (!entry.original) {
|
|
121
|
+
let namespaces = '';
|
|
122
|
+
withoutPersisting(module, () => { namespaces = module.disable(); });
|
|
123
|
+
entry.original = { namespaces, log: module.log };
|
|
124
|
+
}
|
|
125
|
+
const combined = [entry.original.namespaces, pending.namespaces].filter(Boolean).join(',');
|
|
126
|
+
withoutPersisting(module, () => module.enable(combined));
|
|
127
|
+
module.log = pending.log ?? entry.original.log;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function restore(entry: Entry): void {
|
|
131
|
+
const original = entry.original;
|
|
132
|
+
if (!original) return;
|
|
133
|
+
withoutPersisting(entry.module, () => entry.module.enable(original.namespaces));
|
|
134
|
+
entry.module.log = original.log;
|
|
135
|
+
entry.original = undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Comma-join, trimming each entry and dropping empty ones. A string may already hold a list. */
|
|
139
|
+
function normalizeNamespaces(namespaces: string | readonly string[]): string {
|
|
140
|
+
const parts = typeof namespaces === 'string' ? [namespaces] : namespaces;
|
|
141
|
+
return parts
|
|
142
|
+
.flatMap(part => part.split(','))
|
|
143
|
+
.map(part => part.trim())
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join(',');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function confirmationLine(report: OptimysticLoggingReport): string {
|
|
149
|
+
const what = report.namespaces ? `"${report.namespaces}"` : 'nothing (empty namespace list)';
|
|
150
|
+
const trailer = 'libp2p:* is separate, see docs/debugging.md';
|
|
151
|
+
if (report.copies.length === 0) {
|
|
152
|
+
return `optimystic logging on: ${what}; no debug copies registered yet, namespaces will apply as Optimystic packages load; ${trailer}`;
|
|
153
|
+
}
|
|
154
|
+
const count = report.copies.length;
|
|
155
|
+
const groups = report.copies.map(owners => owners.join(', ')).join(' | ');
|
|
156
|
+
return `optimystic logging on: ${what} across ${count} debug ${count === 1 ? 'copy' : 'copies'} [${groups}]; ${trailer}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Called once from each package's `src/logger.ts` at module load, with the `debug` module that file
|
|
161
|
+
* imported. Idempotent per (owner, module); several owners sharing one copy are one entry.
|
|
162
|
+
*
|
|
163
|
+
* With no prior `enableOptimysticLogging` call this touches nothing, so `DEBUG=` on Node behaves
|
|
164
|
+
* exactly as it always has. After one, the arriving copy gets the same namespaces and sink at once
|
|
165
|
+
* (its own baseline captured first), silently — the enable call already confirmed.
|
|
166
|
+
*/
|
|
167
|
+
export function registerDebugModule(owner: string, module: DebugModule): void {
|
|
168
|
+
const state = registry();
|
|
169
|
+
const existing = state.entries.find(entry => entry.module === module);
|
|
170
|
+
if (existing) {
|
|
171
|
+
if (!existing.owners.includes(owner)) existing.owners.push(owner);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const entry: Entry = { module, owners: [owner] };
|
|
175
|
+
state.entries.push(entry);
|
|
176
|
+
if (state.pending) apply(entry, state.pending);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Turn on the given namespaces on every registered `debug` copy, now and for copies that register
|
|
181
|
+
* later — the way to enable Optimystic logging on any runtime, React Native included, without
|
|
182
|
+
* environment variables.
|
|
183
|
+
*
|
|
184
|
+
* Adds to each copy's existing namespaces rather than replacing them, so an app's own channels
|
|
185
|
+
* sharing a copy stay on. A second call replaces the first call's namespaces and `log`, and does not
|
|
186
|
+
* accumulate. An empty list enables nothing of ours (and is not an error).
|
|
187
|
+
*
|
|
188
|
+
* Always writes one confirmation line — through `options.log` if given, else the first registered
|
|
189
|
+
* copy's sink, else `console.log` — because silence is exactly what cannot be told apart from "the
|
|
190
|
+
* code never ran". No line: this was not called, or the sink is swallowed. A line and no events: the
|
|
191
|
+
* code did not run, or the filter does not match. Events: it works.
|
|
192
|
+
*/
|
|
193
|
+
export function enableOptimysticLogging(
|
|
194
|
+
namespaces: string | readonly string[],
|
|
195
|
+
options?: OptimysticLoggingOptions,
|
|
196
|
+
): OptimysticLoggingReport {
|
|
197
|
+
const state = registry();
|
|
198
|
+
const pending: Pending = { namespaces: normalizeNamespaces(namespaces), log: options?.log };
|
|
199
|
+
state.pending = pending;
|
|
200
|
+
for (const entry of state.entries) apply(entry, pending);
|
|
201
|
+
|
|
202
|
+
const report: OptimysticLoggingReport = {
|
|
203
|
+
namespaces: pending.namespaces,
|
|
204
|
+
copies: state.entries.map(entry => [...entry.owners]),
|
|
205
|
+
};
|
|
206
|
+
const line = confirmationLine(report);
|
|
207
|
+
const sink = options?.log ?? state.entries[0]?.module.log;
|
|
208
|
+
if (sink) {
|
|
209
|
+
sink(line);
|
|
210
|
+
} else {
|
|
211
|
+
// No Optimystic package has loaded yet, so there is no debug sink to write through — and the
|
|
212
|
+
// confirmation line must never be skipped (see above).
|
|
213
|
+
// eslint-disable-next-line no-console
|
|
214
|
+
console.log(line);
|
|
215
|
+
}
|
|
216
|
+
return report;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Undo it: every copy goes back to what it had enabled before the first enable call, and its original sink. Safe to call when nothing is enabled. */
|
|
220
|
+
export function disableOptimysticLogging(): void {
|
|
221
|
+
const state = registry();
|
|
222
|
+
for (const entry of state.entries) restore(entry);
|
|
223
|
+
state.pending = undefined;
|
|
224
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import debug from 'debug'
|
|
2
|
+
import { registerDebugModule } from './logger-registry.js'
|
|
2
3
|
|
|
3
4
|
const BASE_NAMESPACE = 'optimystic:db-core'
|
|
4
5
|
|
|
6
|
+
// So `enableOptimysticLogging` reaches this package's copy of `debug`, which may be no one else's.
|
|
7
|
+
registerDebugModule('db-core', debug)
|
|
8
|
+
|
|
5
9
|
export function createLogger(subNamespace: string): debug.Debugger {
|
|
6
10
|
return debug(`${BASE_NAMESPACE}:${subNamespace}`)
|
|
7
11
|
}
|
|
@@ -154,9 +154,14 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
154
154
|
// missing block has already happened one layer down: CoordinatorRepo.get detects
|
|
155
155
|
// `isMissing` and consults cluster peers before it responds — and when that
|
|
156
156
|
// consult FAILS, the entry now says so via `unavailable` instead of posing as an
|
|
157
|
-
// authoritative absent.
|
|
158
|
-
//
|
|
159
|
-
//
|
|
157
|
+
// authoritative absent. An unflagged absent means the cohort confirmed the absence
|
|
158
|
+
// within the last `readRepairWindowMs` (a coordinator that is the block's whole
|
|
159
|
+
// cohort remembers its absence for one window — the same currency bound a held
|
|
160
|
+
// block's content carries; a multi-peer cohort's absence is re-asked on every read).
|
|
161
|
+
// So by the time an unflagged absent reaches here there is nothing left for a
|
|
162
|
+
// transactor-level retry to discover: another coordinator would find the same
|
|
163
|
+
// cohort's same answer. A flagged entry earns the retry against a different peer
|
|
164
|
+
// that an absent deliberately does not.
|
|
160
165
|
// See tickets txn-perf-authoritative-notfound and repo-reports-unavailable-vs-absent.
|
|
161
166
|
const hasValidResponse = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
|
|
162
167
|
return b.request?.isResponse === true && b.request.response != null;
|