@net-mesh/browser 0.37.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/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- package/package.json +72 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local half of a store: state, subscriptions, status and the
|
|
3
|
+
* synchronous transaction.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately transport-free. Both the authoritative host and
|
|
6
|
+
* a joined replica are this object plus a network layer, which is why
|
|
7
|
+
* every rule that does *not* need a peer lives here and is testable
|
|
8
|
+
* without a mesh: immutability, identity preservation, selector
|
|
9
|
+
* equality, notification counting, and the transaction contract.
|
|
10
|
+
*
|
|
11
|
+
* **The transaction contract is the sharp part.** A handler runs
|
|
12
|
+
* synchronously, stages changes, and either commits one revision or
|
|
13
|
+
* discards everything:
|
|
14
|
+
*
|
|
15
|
+
* - `getState()` inside a handler reads the *staged* value, so a
|
|
16
|
+
* handler sees its own writes.
|
|
17
|
+
* - A throw discards the staged changes. Nothing is published.
|
|
18
|
+
* - Returning a thenable is refused, because an `async` handler would
|
|
19
|
+
* commit after its transaction closed — the TypeScript signature
|
|
20
|
+
* already says `undefined`, and JavaScript callers bypass types.
|
|
21
|
+
* - The context is valid only for that transaction. A retained context
|
|
22
|
+
* cannot write from a later microtask.
|
|
23
|
+
* - Read-only callbacks (validators, `authorize`, `project`, update
|
|
24
|
+
* functions) may not reenter a setter.
|
|
25
|
+
* - A public owner `setState` called synchronously *inside* a handler
|
|
26
|
+
* joins the same staged transaction rather than committing beside it,
|
|
27
|
+
* so a handler's rollback cannot be defeated by going through the
|
|
28
|
+
* public handle.
|
|
29
|
+
*/
|
|
30
|
+
import { type PatchOutcome } from './patch.js';
|
|
31
|
+
import type { WireOp } from './wire.js';
|
|
32
|
+
import type { Cancel, ReadonlyState, SelectorOptions, StateUpdate, StoreDefinition, StorePhase, StoreStatus, ActionContext, ActionSpec, InputSpec } from './types.js';
|
|
33
|
+
/**
|
|
34
|
+
* The transport-free store.
|
|
35
|
+
*
|
|
36
|
+
* `S` is the validated state shape. The definition is held for its
|
|
37
|
+
* `state` validator, which every commit passes through — an owner
|
|
38
|
+
* cannot publish state its own schema rejects.
|
|
39
|
+
*/
|
|
40
|
+
export declare class StoreCore<S extends object, A extends ActionSpec, I extends InputSpec> {
|
|
41
|
+
#private;
|
|
42
|
+
constructor(options: {
|
|
43
|
+
definition: StoreDefinition<S, A, I>;
|
|
44
|
+
initialState: S;
|
|
45
|
+
phase?: StorePhase;
|
|
46
|
+
});
|
|
47
|
+
/** The current immutable snapshot. Never initiates I/O. */
|
|
48
|
+
getState(): ReadonlyState<S>;
|
|
49
|
+
/** Monotone count of applied transactions. Unchanged updates do not move it. */
|
|
50
|
+
get revision(): number;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a synchronous transaction is open on this store.
|
|
53
|
+
*
|
|
54
|
+
* The owner's public `commit` reads this to DEFER its delta
|
|
55
|
+
* emission to the transaction's commit: no frame may leave for
|
|
56
|
+
* state the transaction may still discard (#5).
|
|
57
|
+
*/
|
|
58
|
+
get inTransaction(): boolean;
|
|
59
|
+
subscribe(listener: (state: ReadonlyState<S>, previous: ReadonlyState<S>) => void): Cancel;
|
|
60
|
+
subscribe<T>(selector: (state: ReadonlyState<S>) => T, listener: (selected: T, previous: T) => void, options?: SelectorOptions<T>): Cancel;
|
|
61
|
+
getStatus(): StoreStatus;
|
|
62
|
+
subscribeStatus(listener: (status: StoreStatus, previous: StoreStatus) => void): Cancel;
|
|
63
|
+
/**
|
|
64
|
+
* Move readiness. A transition to an identical status is not a
|
|
65
|
+
* transition: the object keeps its identity and nobody is notified.
|
|
66
|
+
*/
|
|
67
|
+
setStatus(patch: Partial<StoreStatus>): void;
|
|
68
|
+
/**
|
|
69
|
+
* Run one handler as a transaction and commit its staged changes.
|
|
70
|
+
*
|
|
71
|
+
* Returns whatever the handler returned. A throw propagates with
|
|
72
|
+
* nothing published.
|
|
73
|
+
*/
|
|
74
|
+
transact<R>(run: (context: ActionContext<S>) => R, peer: string): R;
|
|
75
|
+
/**
|
|
76
|
+
* The owner's public write. Inside a handler it joins that handler's
|
|
77
|
+
* transaction; outside one it is its own single-revision transaction.
|
|
78
|
+
*/
|
|
79
|
+
applyOwnerUpdate(update: StateUpdate<S>): void;
|
|
80
|
+
/**
|
|
81
|
+
* Apply a full snapshot: validate, then reconcile.
|
|
82
|
+
*
|
|
83
|
+
* An equivalent snapshot is a no-op — same root reference, no
|
|
84
|
+
* notifications — which is what stops a periodic resynchronization
|
|
85
|
+
* from looking like a world change to every selector.
|
|
86
|
+
*
|
|
87
|
+
* Inside a handler it JOINS the open transaction, exactly as
|
|
88
|
+
* `applyOwnerUpdate` does: the owner's public `setState` reaches
|
|
89
|
+
* here through `commit`, and committing beside the transaction
|
|
90
|
+
* published a handler's world while `transact` then reported
|
|
91
|
+
* `action-rejected` for it — or, on the committing path, silently
|
|
92
|
+
* reverted the nested write after subscribers had seen it.
|
|
93
|
+
*/
|
|
94
|
+
applySnapshot(raw: unknown): void;
|
|
95
|
+
/**
|
|
96
|
+
* Write entities of one declared collection: each changed entity
|
|
97
|
+
* validated by its PER-ENTITY parser (`definition.entities`), and the
|
|
98
|
+
* document committed WITHOUT the whole-document validator.
|
|
99
|
+
*
|
|
100
|
+
* `changes` maps an id to its new value, or to `undefined` to remove
|
|
101
|
+
* it. The untouched entities keep their identity, so reconciliation
|
|
102
|
+
* and every downstream diff short-circuit on them. What is not free:
|
|
103
|
+
* rebuilding the collection object, O(entities) shallow and with no
|
|
104
|
+
* parsing.
|
|
105
|
+
*
|
|
106
|
+
* Inside a handler it JOINS the open transaction, like
|
|
107
|
+
* {@link applySnapshot}. The whole-document validator is skipped by
|
|
108
|
+
* contract: declaring the collection in `entities` says `state`
|
|
109
|
+
* imposes nothing on it beyond each entity passing its parser.
|
|
110
|
+
*/
|
|
111
|
+
applyEntities(collection: string, changes: Readonly<Record<string, unknown>>): void;
|
|
112
|
+
/**
|
|
113
|
+
* Apply a replica delta: the owner's patch operations, at this
|
|
114
|
+
* document, validated by this definition.
|
|
115
|
+
*
|
|
116
|
+
* This lives here rather than in the replica because the store owns
|
|
117
|
+
* the document, its validator and its revision. A replica that read
|
|
118
|
+
* the state out, patched it outside and pushed the result back would
|
|
119
|
+
* validate twice per delta and would be free to publish something
|
|
120
|
+
* the definition never saw.
|
|
121
|
+
*
|
|
122
|
+
* Refusals are returned, not thrown: a delta that does not apply is
|
|
123
|
+
* a recoverable divergence (§1.8 resynchronization), not a caller
|
|
124
|
+
* error.
|
|
125
|
+
*/
|
|
126
|
+
applyDelta(ops: readonly WireOp[]): PatchOutcome<S>;
|
|
127
|
+
/**
|
|
128
|
+
* Run a read-only callback with setters fenced off.
|
|
129
|
+
*
|
|
130
|
+
* The host layer routes `authorize` and `project` through this, so a
|
|
131
|
+
* callback that is documented as read-only cannot quietly mutate.
|
|
132
|
+
*/
|
|
133
|
+
runReadOnly<R>(run: () => R): R;
|
|
134
|
+
/** Fence the handle: closed, stale, no listeners retained. */
|
|
135
|
+
close(): void;
|
|
136
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local half of a store: state, subscriptions, status and the
|
|
3
|
+
* synchronous transaction.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately transport-free. Both the authoritative host and
|
|
6
|
+
* a joined replica are this object plus a network layer, which is why
|
|
7
|
+
* every rule that does *not* need a peer lives here and is testable
|
|
8
|
+
* without a mesh: immutability, identity preservation, selector
|
|
9
|
+
* equality, notification counting, and the transaction contract.
|
|
10
|
+
*
|
|
11
|
+
* **The transaction contract is the sharp part.** A handler runs
|
|
12
|
+
* synchronously, stages changes, and either commits one revision or
|
|
13
|
+
* discards everything:
|
|
14
|
+
*
|
|
15
|
+
* - `getState()` inside a handler reads the *staged* value, so a
|
|
16
|
+
* handler sees its own writes.
|
|
17
|
+
* - A throw discards the staged changes. Nothing is published.
|
|
18
|
+
* - Returning a thenable is refused, because an `async` handler would
|
|
19
|
+
* commit after its transaction closed — the TypeScript signature
|
|
20
|
+
* already says `undefined`, and JavaScript callers bypass types.
|
|
21
|
+
* - The context is valid only for that transaction. A retained context
|
|
22
|
+
* cannot write from a later microtask.
|
|
23
|
+
* - Read-only callbacks (validators, `authorize`, `project`, update
|
|
24
|
+
* functions) may not reenter a setter.
|
|
25
|
+
* - A public owner `setState` called synchronously *inside* a handler
|
|
26
|
+
* joins the same staged transaction rather than committing beside it,
|
|
27
|
+
* so a handler's rollback cannot be defeated by going through the
|
|
28
|
+
* public handle.
|
|
29
|
+
*/
|
|
30
|
+
import { StoreError } from './errors.js';
|
|
31
|
+
import { mergeShallow, reconcile } from './state.js';
|
|
32
|
+
import { isEntityMap } from './definition.js';
|
|
33
|
+
import { applyPatch } from './patch.js';
|
|
34
|
+
/**
|
|
35
|
+
* The transport-free store.
|
|
36
|
+
*
|
|
37
|
+
* `S` is the validated state shape. The definition is held for its
|
|
38
|
+
* `state` validator, which every commit passes through — an owner
|
|
39
|
+
* cannot publish state its own schema rejects.
|
|
40
|
+
*/
|
|
41
|
+
export class StoreCore {
|
|
42
|
+
#definition;
|
|
43
|
+
#subscriptions = new Set();
|
|
44
|
+
#statusListeners = new Set();
|
|
45
|
+
#state;
|
|
46
|
+
#status;
|
|
47
|
+
#revision = 0;
|
|
48
|
+
#transaction = null;
|
|
49
|
+
#readOnlyDepth = 0;
|
|
50
|
+
#closed = false;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.#definition = options.definition;
|
|
53
|
+
// Validate and freeze once at construction: from here on the only
|
|
54
|
+
// way in is a commit, and every commit reconciles against this.
|
|
55
|
+
this.#state = reconcile(undefined, this.#validate(options.initialState));
|
|
56
|
+
this.#status = Object.freeze({
|
|
57
|
+
phase: options.phase ?? 'ready',
|
|
58
|
+
stale: false,
|
|
59
|
+
error: null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** The current immutable snapshot. Never initiates I/O. */
|
|
63
|
+
getState() {
|
|
64
|
+
return this.#state;
|
|
65
|
+
}
|
|
66
|
+
/** Monotone count of applied transactions. Unchanged updates do not move it. */
|
|
67
|
+
get revision() {
|
|
68
|
+
return this.#revision;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether a synchronous transaction is open on this store.
|
|
72
|
+
*
|
|
73
|
+
* The owner's public `commit` reads this to DEFER its delta
|
|
74
|
+
* emission to the transaction's commit: no frame may leave for
|
|
75
|
+
* state the transaction may still discard (#5).
|
|
76
|
+
*/
|
|
77
|
+
get inTransaction() {
|
|
78
|
+
return this.#transaction !== null && this.#transaction.active;
|
|
79
|
+
}
|
|
80
|
+
subscribe(first, second, options) {
|
|
81
|
+
const subscription = second === undefined
|
|
82
|
+
? this.#rootSubscription(first)
|
|
83
|
+
: this.#selectorSubscription(first, second, options);
|
|
84
|
+
this.#subscriptions.add(subscription);
|
|
85
|
+
let live = true;
|
|
86
|
+
return () => {
|
|
87
|
+
if (!live)
|
|
88
|
+
return;
|
|
89
|
+
live = false;
|
|
90
|
+
this.#subscriptions.delete(subscription);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
getStatus() {
|
|
94
|
+
return this.#status;
|
|
95
|
+
}
|
|
96
|
+
subscribeStatus(listener) {
|
|
97
|
+
this.#statusListeners.add(listener);
|
|
98
|
+
let live = true;
|
|
99
|
+
return () => {
|
|
100
|
+
if (!live)
|
|
101
|
+
return;
|
|
102
|
+
live = false;
|
|
103
|
+
this.#statusListeners.delete(listener);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Move readiness. A transition to an identical status is not a
|
|
108
|
+
* transition: the object keeps its identity and nobody is notified.
|
|
109
|
+
*/
|
|
110
|
+
setStatus(patch) {
|
|
111
|
+
const previous = this.#status;
|
|
112
|
+
const candidate = {
|
|
113
|
+
phase: patch.phase ?? previous.phase,
|
|
114
|
+
stale: patch.stale ?? previous.stale,
|
|
115
|
+
error: patch.error === undefined ? previous.error : patch.error,
|
|
116
|
+
};
|
|
117
|
+
if (candidate.phase === previous.phase &&
|
|
118
|
+
candidate.stale === previous.stale &&
|
|
119
|
+
Object.is(candidate.error, previous.error)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.#status = Object.freeze(candidate);
|
|
123
|
+
for (const listener of [...this.#statusListeners]) {
|
|
124
|
+
// Same rule as the state pass: a cancellation that has already
|
|
125
|
+
// returned is honoured for the rest of this pass.
|
|
126
|
+
if (!this.#statusListeners.has(listener))
|
|
127
|
+
continue;
|
|
128
|
+
dispatch(() => listener(this.#status, previous));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Run one handler as a transaction and commit its staged changes.
|
|
133
|
+
*
|
|
134
|
+
* Returns whatever the handler returned. A throw propagates with
|
|
135
|
+
* nothing published.
|
|
136
|
+
*/
|
|
137
|
+
transact(run, peer) {
|
|
138
|
+
this.#refuseWhenClosed();
|
|
139
|
+
if (this.#transaction !== null) {
|
|
140
|
+
throw new StoreError('action-rejected', 'a store transaction is already open on this store; handlers are synchronous and must not nest');
|
|
141
|
+
}
|
|
142
|
+
const transaction = { staged: this.#state, active: true };
|
|
143
|
+
this.#transaction = transaction;
|
|
144
|
+
try {
|
|
145
|
+
const result = run(this.#context(transaction, peer));
|
|
146
|
+
if (isThenable(result)) {
|
|
147
|
+
throw new StoreError('action-rejected', 'a store handler returned a thenable; handlers are synchronous so that a throw can discard the whole transaction');
|
|
148
|
+
}
|
|
149
|
+
this.#commit(transaction.staged);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
// Invalidate before clearing, so a context captured by the
|
|
154
|
+
// handler is dead even if the handler stashed it somewhere.
|
|
155
|
+
transaction.active = false;
|
|
156
|
+
this.#transaction = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The owner's public write. Inside a handler it joins that handler's
|
|
161
|
+
* transaction; outside one it is its own single-revision transaction.
|
|
162
|
+
*/
|
|
163
|
+
applyOwnerUpdate(update) {
|
|
164
|
+
this.#refuseWhenClosed();
|
|
165
|
+
const open = this.#transaction;
|
|
166
|
+
if (open !== null && open.active) {
|
|
167
|
+
open.staged = this.#stage(open.staged, update);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const staged = this.#stage(this.#state, update);
|
|
171
|
+
this.#commit(staged);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Apply a full snapshot: validate, then reconcile.
|
|
175
|
+
*
|
|
176
|
+
* An equivalent snapshot is a no-op — same root reference, no
|
|
177
|
+
* notifications — which is what stops a periodic resynchronization
|
|
178
|
+
* from looking like a world change to every selector.
|
|
179
|
+
*
|
|
180
|
+
* Inside a handler it JOINS the open transaction, exactly as
|
|
181
|
+
* `applyOwnerUpdate` does: the owner's public `setState` reaches
|
|
182
|
+
* here through `commit`, and committing beside the transaction
|
|
183
|
+
* published a handler's world while `transact` then reported
|
|
184
|
+
* `action-rejected` for it — or, on the committing path, silently
|
|
185
|
+
* reverted the nested write after subscribers had seen it.
|
|
186
|
+
*/
|
|
187
|
+
applySnapshot(raw) {
|
|
188
|
+
this.#refuseWhenClosed();
|
|
189
|
+
const open = this.#transaction;
|
|
190
|
+
if (open !== null && open.active) {
|
|
191
|
+
open.staged = this.#validate(raw);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.#commit(this.#validate(raw));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Write entities of one declared collection: each changed entity
|
|
198
|
+
* validated by its PER-ENTITY parser (`definition.entities`), and the
|
|
199
|
+
* document committed WITHOUT the whole-document validator.
|
|
200
|
+
*
|
|
201
|
+
* `changes` maps an id to its new value, or to `undefined` to remove
|
|
202
|
+
* it. The untouched entities keep their identity, so reconciliation
|
|
203
|
+
* and every downstream diff short-circuit on them. What is not free:
|
|
204
|
+
* rebuilding the collection object, O(entities) shallow and with no
|
|
205
|
+
* parsing.
|
|
206
|
+
*
|
|
207
|
+
* Inside a handler it JOINS the open transaction, like
|
|
208
|
+
* {@link applySnapshot}. The whole-document validator is skipped by
|
|
209
|
+
* contract: declaring the collection in `entities` says `state`
|
|
210
|
+
* imposes nothing on it beyond each entity passing its parser.
|
|
211
|
+
*/
|
|
212
|
+
applyEntities(collection, changes) {
|
|
213
|
+
this.#refuseWhenClosed();
|
|
214
|
+
const parse = this.#definition.entities?.[collection];
|
|
215
|
+
if (parse === undefined || !Object.prototype.hasOwnProperty.call(this.#definition.entities, collection)) {
|
|
216
|
+
throw new StoreError('invalid-data', `store '${this.#definition.id}' declares no per-entity parser for '${collection}'; declare it in \`entities\` or write through setState`);
|
|
217
|
+
}
|
|
218
|
+
const open = this.#transaction;
|
|
219
|
+
const from = open !== null && open.active ? open.staged : this.#state;
|
|
220
|
+
const current = from[collection];
|
|
221
|
+
if (current !== undefined && !isEntityMap(current)) {
|
|
222
|
+
throw new StoreError('invalid-data', `store '${this.#definition.id}': '${collection}' is not an entity map in the current state`);
|
|
223
|
+
}
|
|
224
|
+
// Every change parsed BEFORE anything is built: a refusal on the last
|
|
225
|
+
// id publishes nothing.
|
|
226
|
+
const parsed = [];
|
|
227
|
+
for (const id of Object.keys(changes)) {
|
|
228
|
+
if (id.length === 0) {
|
|
229
|
+
throw new StoreError('invalid-data', `store '${this.#definition.id}': an entity id in '${collection}' is empty`);
|
|
230
|
+
}
|
|
231
|
+
const raw = changes[id];
|
|
232
|
+
if (raw === undefined) {
|
|
233
|
+
parsed.push([id, undefined]);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
parsed.push([id, this.runReadOnly(() => parse(raw))]);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error instanceof StoreError)
|
|
241
|
+
throw error;
|
|
242
|
+
throw new StoreError('invalid-data', `entity '${collection}.${id}' failed the '${this.#definition.id}' entity validator: ${String(error)}`, { cause: error });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (open !== null && open.active) {
|
|
246
|
+
// A staged document need not be reconciled yet (a `setState` inside
|
|
247
|
+
// the handler stages the validator's raw output), so the transaction
|
|
248
|
+
// gets the general merge; its commit validates the document anyway.
|
|
249
|
+
const draft = { ...(current ?? {}) };
|
|
250
|
+
for (const [id, entity] of parsed) {
|
|
251
|
+
if (entity === undefined)
|
|
252
|
+
delete draft[id];
|
|
253
|
+
else
|
|
254
|
+
defineData(draft, id, entity);
|
|
255
|
+
}
|
|
256
|
+
open.staged = mergeShallow(from, { [collection]: draft });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Outside a transaction `from` is the committed state: reconciled and
|
|
260
|
+
// frozen all the way down. So only the CHANGED entities are reconciled,
|
|
261
|
+
// the collection is rebuilt by a spread (which defines own properties,
|
|
262
|
+
// `__proto__` included, and parses nothing), and the result is
|
|
263
|
+
// installed without a second whole-tree reconcile — `reconcile` over the
|
|
264
|
+
// collection is exactly the O(world) walk this path exists to skip.
|
|
265
|
+
let changed = false;
|
|
266
|
+
const reconciled = [];
|
|
267
|
+
for (const [id, entity] of parsed) {
|
|
268
|
+
const present = current !== undefined && Object.prototype.hasOwnProperty.call(current, id);
|
|
269
|
+
const before = present ? current[id] : undefined;
|
|
270
|
+
if (entity === undefined) {
|
|
271
|
+
if (present)
|
|
272
|
+
changed = true;
|
|
273
|
+
reconciled.push([id, undefined]);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const next = reconcile(before, entity);
|
|
277
|
+
if (!Object.is(before, next))
|
|
278
|
+
changed = true;
|
|
279
|
+
reconciled.push([id, next]);
|
|
280
|
+
}
|
|
281
|
+
if (!changed)
|
|
282
|
+
return;
|
|
283
|
+
const map = { ...(current ?? {}) };
|
|
284
|
+
for (const [id, entity] of reconciled) {
|
|
285
|
+
if (entity === undefined)
|
|
286
|
+
delete map[id];
|
|
287
|
+
else
|
|
288
|
+
defineData(map, id, entity);
|
|
289
|
+
}
|
|
290
|
+
const root = { ...from };
|
|
291
|
+
defineData(root, collection, Object.freeze(map));
|
|
292
|
+
this.#install(Object.freeze(root));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Apply a replica delta: the owner's patch operations, at this
|
|
296
|
+
* document, validated by this definition.
|
|
297
|
+
*
|
|
298
|
+
* This lives here rather than in the replica because the store owns
|
|
299
|
+
* the document, its validator and its revision. A replica that read
|
|
300
|
+
* the state out, patched it outside and pushed the result back would
|
|
301
|
+
* validate twice per delta and would be free to publish something
|
|
302
|
+
* the definition never saw.
|
|
303
|
+
*
|
|
304
|
+
* Refusals are returned, not thrown: a delta that does not apply is
|
|
305
|
+
* a recoverable divergence (§1.8 resynchronization), not a caller
|
|
306
|
+
* error.
|
|
307
|
+
*/
|
|
308
|
+
applyDelta(ops) {
|
|
309
|
+
this.#refuseWhenClosed();
|
|
310
|
+
const outcome = applyPatch(this.#state, ops, raw => this.#validate(raw));
|
|
311
|
+
// `applyPatch` has ALREADY run the validator on the patched
|
|
312
|
+
// document — that is what it was handed one for. Committing
|
|
313
|
+
// through the validating path ran it a second time, and a
|
|
314
|
+
// validator is not required to be idempotent: a parser that
|
|
315
|
+
// derives or normalizes a field applied its transformation twice,
|
|
316
|
+
// so the outcome this returns and the document a subscriber sees
|
|
317
|
+
// were different values. One validation per document.
|
|
318
|
+
if (outcome.ok && outcome.changed)
|
|
319
|
+
this.#publish(outcome.next);
|
|
320
|
+
return outcome;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Run a read-only callback with setters fenced off.
|
|
324
|
+
*
|
|
325
|
+
* The host layer routes `authorize` and `project` through this, so a
|
|
326
|
+
* callback that is documented as read-only cannot quietly mutate.
|
|
327
|
+
*/
|
|
328
|
+
runReadOnly(run) {
|
|
329
|
+
this.#readOnlyDepth += 1;
|
|
330
|
+
try {
|
|
331
|
+
return run();
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
this.#readOnlyDepth -= 1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Fence the handle: closed, stale, no listeners retained. */
|
|
338
|
+
close() {
|
|
339
|
+
if (this.#closed)
|
|
340
|
+
return;
|
|
341
|
+
this.#closed = true;
|
|
342
|
+
this.setStatus({ phase: 'closed', stale: true });
|
|
343
|
+
this.#subscriptions.clear();
|
|
344
|
+
this.#statusListeners.clear();
|
|
345
|
+
}
|
|
346
|
+
#context(transaction, peer) {
|
|
347
|
+
return {
|
|
348
|
+
peer,
|
|
349
|
+
getState: () => {
|
|
350
|
+
this.#refuseEscapedContext(transaction);
|
|
351
|
+
return transaction.staged;
|
|
352
|
+
},
|
|
353
|
+
setState: (update) => {
|
|
354
|
+
this.#refuseEscapedContext(transaction);
|
|
355
|
+
if (this.#readOnlyDepth > 0) {
|
|
356
|
+
throw new StoreError('action-rejected', 'a read-only store callback may not write state; validators, authorize, project and update functions run with setters fenced');
|
|
357
|
+
}
|
|
358
|
+
transaction.staged = this.#stage(transaction.staged, update);
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
#stage(from, update) {
|
|
363
|
+
const patch = typeof update === 'function'
|
|
364
|
+
? this.runReadOnly(() => update(from))
|
|
365
|
+
: update;
|
|
366
|
+
return mergeShallow(from, patch);
|
|
367
|
+
}
|
|
368
|
+
#commit(candidate) {
|
|
369
|
+
this.#publish(this.#validate(candidate));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Reconcile and publish a document that has ALREADY been validated.
|
|
373
|
+
*
|
|
374
|
+
* Split from {@link #commit} so a caller that validated can say so
|
|
375
|
+
* instead of validating again. Everything below this line is
|
|
376
|
+
* reconciliation, revision and notification — never validation.
|
|
377
|
+
*/
|
|
378
|
+
#publish(validated) {
|
|
379
|
+
const next = reconcile(this.#state, validated);
|
|
380
|
+
if (Object.is(next, this.#state))
|
|
381
|
+
return;
|
|
382
|
+
this.#install(next);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Install a document that is ALREADY reconciled against the current one
|
|
386
|
+
* and frozen, and tell the subscribers. Only {@link applyEntities}
|
|
387
|
+
* builds such a document itself; everything else comes through
|
|
388
|
+
* {@link #publish}.
|
|
389
|
+
*/
|
|
390
|
+
#install(next) {
|
|
391
|
+
const previous = this.#state;
|
|
392
|
+
this.#state = next;
|
|
393
|
+
this.#revision += 1;
|
|
394
|
+
// The snapshot keeps a listener added DURING this pass out of it,
|
|
395
|
+
// and re-checking membership keeps a listener CANCELLED during it
|
|
396
|
+
// out too. Both are the same question asked at the right moment: a
|
|
397
|
+
// `cancel()` that has already returned must not be followed by a
|
|
398
|
+
// call, or a listener that has torn down its own scene graph gets
|
|
399
|
+
// handed one more frame of state it no longer expects.
|
|
400
|
+
for (const subscription of [...this.#subscriptions]) {
|
|
401
|
+
if (!this.#subscriptions.has(subscription))
|
|
402
|
+
continue;
|
|
403
|
+
subscription.notify(next, previous);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
#validate(raw) {
|
|
407
|
+
try {
|
|
408
|
+
return this.runReadOnly(() => this.#definition.state(raw));
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (error instanceof StoreError)
|
|
412
|
+
throw error;
|
|
413
|
+
throw new StoreError('invalid-data', `state failed the '${this.#definition.id}' validator: ${String(error)}`, { cause: error });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
#rootSubscription(listener) {
|
|
417
|
+
return {
|
|
418
|
+
notify: (next, previous) => {
|
|
419
|
+
dispatch(() => listener(next, previous));
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
#selectorSubscription(selector, listener, options) {
|
|
424
|
+
const equality = options?.equality ?? Object.is;
|
|
425
|
+
let last = this.runReadOnly(() => selector(this.#state));
|
|
426
|
+
if (options?.fireImmediately === true) {
|
|
427
|
+
dispatch(() => listener(last, last));
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
notify: next => {
|
|
431
|
+
const selected = this.runReadOnly(() => selector(next));
|
|
432
|
+
const previous = last;
|
|
433
|
+
// An unchanged selection is not an event. This is what makes a
|
|
434
|
+
// 60 Hz render loop cheap, and it is asserted, not assumed.
|
|
435
|
+
if (equality(selected, previous))
|
|
436
|
+
return;
|
|
437
|
+
last = selected;
|
|
438
|
+
dispatch(() => listener(selected, previous));
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
#refuseEscapedContext(transaction) {
|
|
443
|
+
if (transaction.active)
|
|
444
|
+
return;
|
|
445
|
+
throw new StoreError('action-rejected', 'this store transaction has already ended; its context cannot read or write from a later turn');
|
|
446
|
+
}
|
|
447
|
+
#refuseWhenClosed() {
|
|
448
|
+
if (!this.#closed)
|
|
449
|
+
return;
|
|
450
|
+
throw new StoreError('closed', 'this store handle is closed');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function isThenable(value) {
|
|
454
|
+
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return typeof value.then === 'function';
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Run one listener in isolation.
|
|
461
|
+
*
|
|
462
|
+
* A listener's bug is not the store's: report it where a page can see
|
|
463
|
+
* it and keep the remaining listeners — and the cleanup that follows
|
|
464
|
+
* them — running. Same convention as the event hub in `events.ts`.
|
|
465
|
+
*/
|
|
466
|
+
function dispatch(deliver) {
|
|
467
|
+
try {
|
|
468
|
+
deliver();
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
console.error('[@net-mesh/browser] store listener threw', error);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** Store a caller's key as data: an id of `__proto__` is not a prototype. */
|
|
475
|
+
function defineData(target, key, value) {
|
|
476
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
477
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineStore` — the reusable typed description.
|
|
3
|
+
*
|
|
4
|
+
* It holds validators, never authority handlers, so a client bundle
|
|
5
|
+
* that joins a store does not carry the owner's gameplay code.
|
|
6
|
+
*
|
|
7
|
+
* It is not a pass-through. It checks the two properties whose
|
|
8
|
+
* violation is invisible until much later:
|
|
9
|
+
*
|
|
10
|
+
* 1. `empty()` must itself be a valid state. `empty()` is what a
|
|
11
|
+
* replica installs when it loses visibility, so an `empty()` the
|
|
12
|
+
* schema would reject means audience clearing fails at the worst
|
|
13
|
+
* possible moment — mid-transition, with the old projection already
|
|
14
|
+
* fenced.
|
|
15
|
+
* 2. `id` and `version` must be usable as an incarnation identity.
|
|
16
|
+
* They travel in every envelope and a blank id or a non-integer
|
|
17
|
+
* version turns a version-mismatch refusal into a confusing parse
|
|
18
|
+
* error at the far end.
|
|
19
|
+
*/
|
|
20
|
+
import type { ActionSpec, InputSpec, StoreDefinition } from './types.js';
|
|
21
|
+
export declare function defineStore<S extends object, A extends ActionSpec, I extends InputSpec>(definition: StoreDefinition<S, A, I>): StoreDefinition<S, A, I>;
|
|
22
|
+
/** A plain object: what an entity map is. */
|
|
23
|
+
export declare function isEntityMap(value: unknown): value is Readonly<Record<string, unknown>>;
|