koota-kit 0.6.2 → 0.7.1

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Borrowed-World coordination for completed model metadata.
3
+ *
4
+ * The caller owns the Koota World and its entities. This module only adds its
5
+ * own marker traits and exposes a committed, immutable projection. It does
6
+ * not create a World, run systems, serialize entities, or read geometry.
7
+ */
8
+ import { type Entity, type World } from "koota";
9
+ type BindingMarker = object;
10
+ /**
11
+ * World-held ownership markers for live bindings. Each binding appends one
12
+ * opaque marker and removes only that marker at disposal, so no module-global
13
+ * registry or per-bind Koota trait allocation is needed.
14
+ */
15
+ export declare const CoordinationBinding: import("koota").Trait<{
16
+ markers: () => BindingMarker[];
17
+ }>;
18
+ /**
19
+ * The only trait this module places on caller-owned model entities. Its
20
+ * fields are a diagnostic mirror of the binding's committed projection;
21
+ * callers must use `getSnapshot` for an immutable completed view.
22
+ */
23
+ export declare const CoordinationRegistration: import("koota").Trait<{
24
+ marker: () => BindingMarker;
25
+ generation: number;
26
+ id: string;
27
+ geometryRevision: number;
28
+ poseRevision: number;
29
+ transformRevision: number;
30
+ }>;
31
+ declare const registrationTokenBrand: unique symbol;
32
+ /** An opaque capability token returned only by `Coordination.register`. */
33
+ export type RegistrationToken = {
34
+ readonly [registrationTokenBrand]: never;
35
+ };
36
+ export type CompletedRevisions = Readonly<{
37
+ geometry: number;
38
+ pose: number;
39
+ transform: number;
40
+ }>;
41
+ export type CompletedMetadata = Readonly<{
42
+ revisions: CompletedRevisions;
43
+ }>;
44
+ export type CoordinationDescriptor = Readonly<{
45
+ /** A non-empty caller-authored identity, never a packed Koota entity ID. */
46
+ id: string;
47
+ /** Stable, sorted capability labels. Change them by unregistering first. */
48
+ capabilities: readonly string[];
49
+ }>;
50
+ export type CoordinationEntry = Readonly<{
51
+ id: string;
52
+ capabilities: readonly string[];
53
+ /** Undefined until the producer has explicitly published completed facts. */
54
+ completed: CompletedMetadata | undefined;
55
+ }>;
56
+ export type CoordinationSnapshot = Readonly<{
57
+ /** A binding-local publication generation, not a producer content revision. */
58
+ revision: number;
59
+ /** Entries sorted lexically by stable caller-authored id. */
60
+ entries: readonly CoordinationEntry[];
61
+ }>;
62
+ export type CoordinationUpdate = Readonly<{
63
+ token: RegistrationToken;
64
+ completed: CompletedMetadata;
65
+ }>;
66
+ export type CoordinationListener = (snapshot: CoordinationSnapshot) => void;
67
+ export type Coordination = Readonly<{
68
+ readonly world: World;
69
+ register: (entity: Entity, descriptor: CoordinationDescriptor) => RegistrationToken;
70
+ publish: (token: RegistrationToken, completed: CompletedMetadata) => CoordinationSnapshot;
71
+ publishBatch: (updates: readonly CoordinationUpdate[]) => CoordinationSnapshot;
72
+ unregister: (token: RegistrationToken) => CoordinationSnapshot;
73
+ /** Remove registrations whose entity or owned trait is no longer current. */
74
+ prune: () => CoordinationSnapshot;
75
+ /** Recover a current token after a listener threw during registration. */
76
+ getRegistration: (id: string) => RegistrationToken | undefined;
77
+ /** Resolve a stable identity only while its registered entity remains live. */
78
+ getEntity: (id: string) => Entity | undefined;
79
+ getSnapshot: () => CoordinationSnapshot;
80
+ subscribe: (listener: CoordinationListener) => () => void;
81
+ /** Idempotently remove this module's traits; never destroys the borrowed World. */
82
+ dispose: () => void;
83
+ }>;
84
+ /**
85
+ * Bind to a caller-owned Koota World. A live binding writes its opaque marker
86
+ * onto that same World, without a hidden module-global World registry. Several
87
+ * bindings may coexist only while they register disjoint caller entities.
88
+ */
89
+ export declare function bindCoordination(world: World): Coordination;
90
+ export {};
91
+ //# sourceMappingURL=coordination.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coordination.d.ts","sourceRoot":"","sources":["../../src/coordination.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAS,KAAK,MAAM,EAAE,KAAK,KAAK,EAAE,MAAM,OAAO,CAAC;AAEvD,KAAK,aAAa,GAAG,MAAM,CAAC;AAW5B;;;;GAIG;AACH,eAAO,MAAM,mBAAmB;mBAAgC,aAAa,EAAE;EAAG,CAAC;AAEnF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;kBACb,aAAa;;;;;;EAMnC,CAAC;AAEH,OAAO,CAAC,MAAM,sBAAsB,EAAE,OAAO,MAAM,CAAC;AAEpD,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,CAAC,sBAAsB,CAAC,EAAE,KAAK,CAAC;CAC1C,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,QAAQ,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,SAAS,EAAE,kBAAkB,CAAC;CAC/B,CAAC,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CAAC;IAC5C,4EAA4E;IAC5E,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC,CAAC,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,6EAA6E;IAC7E,SAAS,EAAE,iBAAiB,GAAG,SAAS,CAAC;CAC1C,CAAC,CAAC;AAEH,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,CAAC;IACjB,6DAA6D;IAC7D,OAAO,EAAE,SAAS,iBAAiB,EAAE,CAAC;CACvC,CAAC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,QAAQ,CAAC;IACxC,KAAK,EAAE,iBAAiB,CAAC;IACzB,SAAS,EAAE,iBAAiB,CAAC;CAC9B,CAAC,CAAC;AAEH,MAAM,MAAM,oBAAoB,GAAG,CAAC,QAAQ,EAAE,oBAAoB,KAAK,IAAI,CAAC;AAE5E,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,sBAAsB,KAAK,iBAAiB,CAAC;IACpF,OAAO,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,iBAAiB,KAAK,oBAAoB,CAAC;IAC1F,YAAY,EAAE,CAAC,OAAO,EAAE,SAAS,kBAAkB,EAAE,KAAK,oBAAoB,CAAC;IAC/E,UAAU,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,oBAAoB,CAAC;IAC/D,6EAA6E;IAC7E,KAAK,EAAE,MAAM,oBAAoB,CAAC;IAClC,0EAA0E;IAC1E,eAAe,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,iBAAiB,GAAG,SAAS,CAAC;IAC/D,+EAA+E;IAC/E,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IAC9C,WAAW,EAAE,MAAM,oBAAoB,CAAC;IACxC,SAAS,EAAE,CAAC,QAAQ,EAAE,oBAAoB,KAAK,MAAM,IAAI,CAAC;IAC1D,mFAAmF;IACnF,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC,CAAC;AAwHH;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,GAAG,YAAY,CAgT3D"}
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Borrowed-World coordination for completed model metadata.
3
+ *
4
+ * The caller owns the Koota World and its entities. This module only adds its
5
+ * own marker traits and exposes a committed, immutable projection. It does
6
+ * not create a World, run systems, serialize entities, or read geometry.
7
+ */
8
+ import { trait } from "koota";
9
+ /**
10
+ * World-held ownership markers for live bindings. Each binding appends one
11
+ * opaque marker and removes only that marker at disposal, so no module-global
12
+ * registry or per-bind Koota trait allocation is needed.
13
+ */
14
+ export const CoordinationBinding = trait({ markers: () => [] });
15
+ /**
16
+ * The only trait this module places on caller-owned model entities. Its
17
+ * fields are a diagnostic mirror of the binding's committed projection;
18
+ * callers must use `getSnapshot` for an immutable completed view.
19
+ */
20
+ export const CoordinationRegistration = trait({
21
+ marker: () => ({}),
22
+ generation: 0,
23
+ id: "",
24
+ geometryRevision: 0,
25
+ poseRevision: 0,
26
+ transformRevision: 0,
27
+ });
28
+ const tokenMarker = Symbol("koota-kit.coordination.token");
29
+ function fail(message) {
30
+ throw new Error(`coordination: ${message}`);
31
+ }
32
+ function isObject(value) {
33
+ return typeof value === "object" && value !== null;
34
+ }
35
+ function requireNonEmptyString(value, name) {
36
+ if (typeof value !== "string" || value.trim().length === 0) {
37
+ throw new TypeError(`coordination: ${name} must be a non-empty string.`);
38
+ }
39
+ return value;
40
+ }
41
+ function copyCapabilities(value) {
42
+ if (!Array.isArray(value)) {
43
+ throw new TypeError("coordination: descriptor.capabilities must be an array of non-empty strings.");
44
+ }
45
+ const capabilities = value.map((capability) => requireNonEmptyString(capability, "capability"));
46
+ capabilities.sort((left, right) => Number(left > right) - Number(left < right));
47
+ for (let index = 1; index < capabilities.length; index += 1) {
48
+ if (capabilities[index] === capabilities[index - 1]) {
49
+ throw new TypeError(`coordination: duplicate capability "${capabilities[index]}".`);
50
+ }
51
+ }
52
+ return Object.freeze(capabilities);
53
+ }
54
+ function copyRevisions(value) {
55
+ if (!isObject(value)) {
56
+ throw new TypeError("coordination: completed.revisions must be an object.");
57
+ }
58
+ const names = ["geometry", "pose", "transform"];
59
+ const values = names.map((name) => value[name]);
60
+ for (const [index, revision] of values.entries()) {
61
+ if (!Number.isSafeInteger(revision) || revision < 0) {
62
+ throw new RangeError(`coordination: completed.revisions.${names[index]} must be a non-negative safe integer.`);
63
+ }
64
+ }
65
+ return Object.freeze({
66
+ geometry: values[0],
67
+ pose: values[1],
68
+ transform: values[2],
69
+ });
70
+ }
71
+ function copyCompleted(value) {
72
+ if (!isObject(value)) {
73
+ throw new TypeError("coordination: completed metadata must be an object.");
74
+ }
75
+ return Object.freeze({ revisions: copyRevisions(value.revisions) });
76
+ }
77
+ function sameRevisions(left, right) {
78
+ return (left.geometry === right.geometry &&
79
+ left.pose === right.pose &&
80
+ left.transform === right.transform);
81
+ }
82
+ function assertNoRegression(previous, next) {
83
+ if (!previous)
84
+ return;
85
+ for (const name of ["geometry", "pose", "transform"]) {
86
+ if (next.revisions[name] < previous.revisions[name]) {
87
+ throw new RangeError(`coordination: completed revision ${name} may not decrease.`);
88
+ }
89
+ }
90
+ }
91
+ function makeSnapshot(revision, registrations) {
92
+ const entries = [...registrations]
93
+ .map((registration) => Object.freeze({
94
+ id: registration.id,
95
+ capabilities: registration.capabilities,
96
+ completed: registration.completed,
97
+ }))
98
+ .sort((left, right) => Number(left.id > right.id) - Number(left.id < right.id));
99
+ return Object.freeze({ revision, entries: Object.freeze(entries) });
100
+ }
101
+ function stateIsOwned(state, marker, generation) {
102
+ return state?.marker === marker && state.generation === generation;
103
+ }
104
+ /**
105
+ * Bind to a caller-owned Koota World. A live binding writes its opaque marker
106
+ * onto that same World, without a hidden module-global World registry. Several
107
+ * bindings may coexist only while they register disjoint caller entities.
108
+ */
109
+ export function bindCoordination(world) {
110
+ if (!world?.isInitialized) {
111
+ fail("a live Koota World is required.");
112
+ }
113
+ const marker = Object.freeze({});
114
+ const existingMarkers = world.get(CoordinationBinding)?.markers ?? [];
115
+ const markers = [...existingMarkers, marker];
116
+ try {
117
+ if (world.has(CoordinationBinding))
118
+ world.set(CoordinationBinding, { markers });
119
+ else
120
+ world.add(CoordinationBinding({ markers }));
121
+ }
122
+ catch (error) {
123
+ const currentMarkers = world.get(CoordinationBinding)?.markers;
124
+ if (world.isInitialized && currentMarkers?.includes(marker)) {
125
+ const remaining = currentMarkers.filter((candidate) => candidate !== marker);
126
+ if (remaining.length === 0)
127
+ world.remove(CoordinationBinding);
128
+ else
129
+ world.set(CoordinationBinding, { markers: remaining });
130
+ }
131
+ throw error;
132
+ }
133
+ let disposed = false;
134
+ let notifying = false;
135
+ let mutating = false;
136
+ let nextRegistrationGeneration = 0;
137
+ let nextSnapshotRevision = 0;
138
+ const registrationsById = new Map();
139
+ const registrationsByToken = new Map();
140
+ const listeners = new Set();
141
+ let snapshot = makeSnapshot(nextSnapshotRevision, registrationsById.values());
142
+ const assertActive = () => {
143
+ if (disposed)
144
+ fail("the binding has been disposed.");
145
+ if (notifying || mutating)
146
+ fail("mutating a binding while it notifies listeners is not allowed.");
147
+ };
148
+ const assertMutable = () => {
149
+ assertActive();
150
+ if (!world.isInitialized || !world.get(CoordinationBinding)?.markers.includes(marker)) {
151
+ fail("the binding no longer owns its World marker.");
152
+ }
153
+ };
154
+ const isCurrent = (registration) => {
155
+ if (!world.isInitialized || !world.has(registration.entity))
156
+ return false;
157
+ return stateIsOwned(registration.entity.get(CoordinationRegistration), marker, registration.state.generation);
158
+ };
159
+ const registrationFor = (token) => {
160
+ const registration = registrationsByToken.get(token);
161
+ if (!registration || token[tokenMarker] !== marker) {
162
+ fail("the registration token does not belong to this binding.");
163
+ }
164
+ if (!isCurrent(registration)) {
165
+ fail("the registration token is stale; call prune before re-registering its id.");
166
+ }
167
+ return registration;
168
+ };
169
+ const notify = () => {
170
+ const deliveries = [...listeners];
171
+ const errors = [];
172
+ notifying = true;
173
+ try {
174
+ for (const listener of deliveries) {
175
+ try {
176
+ listener(snapshot);
177
+ }
178
+ catch (error) {
179
+ errors.push(error);
180
+ }
181
+ }
182
+ }
183
+ finally {
184
+ notifying = false;
185
+ }
186
+ if (errors.length === 1)
187
+ throw errors[0];
188
+ if (errors.length > 1) {
189
+ const error = Object.assign(new Error("coordination: listener failures after commit."), {
190
+ errors: Object.freeze(errors),
191
+ });
192
+ throw error;
193
+ }
194
+ };
195
+ const nextCounter = (value, name) => {
196
+ const next = value + 1;
197
+ if (!Number.isSafeInteger(next)) {
198
+ throw new RangeError(`coordination: ${name} exceeded the safe integer range.`);
199
+ }
200
+ return next;
201
+ };
202
+ const commit = (revision) => {
203
+ nextSnapshotRevision = revision;
204
+ snapshot = makeSnapshot(revision, registrationsById.values());
205
+ notify();
206
+ return snapshot;
207
+ };
208
+ const register = (entity, descriptor) => {
209
+ assertMutable();
210
+ if (!world.has(entity))
211
+ fail("entity must be live in this binding's World.");
212
+ if (!isObject(descriptor))
213
+ throw new TypeError("coordination: descriptor must be an object.");
214
+ const id = requireNonEmptyString(descriptor.id, "descriptor.id");
215
+ const capabilities = copyCapabilities(descriptor.capabilities);
216
+ if (registrationsById.has(id))
217
+ fail(`stable id "${id}" is already registered.`);
218
+ if (entity.has(CoordinationRegistration)) {
219
+ fail("entity already carries a coordination registration trait.");
220
+ }
221
+ const registrationGeneration = nextCounter(nextRegistrationGeneration, "registration generation");
222
+ const snapshotRevision = nextCounter(nextSnapshotRevision, "snapshot revision");
223
+ const state = Object.freeze({
224
+ marker,
225
+ generation: registrationGeneration,
226
+ id,
227
+ geometryRevision: 0,
228
+ poseRevision: 0,
229
+ transformRevision: 0,
230
+ });
231
+ const token = Object.freeze({ [tokenMarker]: marker });
232
+ const registration = {
233
+ entity,
234
+ token,
235
+ state,
236
+ id,
237
+ capabilities,
238
+ completed: undefined,
239
+ };
240
+ // Every validation above happens before the Koota marker or this binding's
241
+ // projection changes. Koota callbacks can observe this raw trait; they are
242
+ // deliberately outside the atomic committed-snapshot contract.
243
+ mutating = true;
244
+ try {
245
+ try {
246
+ entity.add(CoordinationRegistration(state));
247
+ }
248
+ catch (error) {
249
+ const current = entity.get(CoordinationRegistration);
250
+ if (stateIsOwned(current, marker, registrationGeneration)) {
251
+ entity.remove(CoordinationRegistration);
252
+ }
253
+ throw error;
254
+ }
255
+ nextRegistrationGeneration = registrationGeneration;
256
+ registrationsById.set(id, registration);
257
+ registrationsByToken.set(token, registration);
258
+ commit(snapshotRevision);
259
+ }
260
+ finally {
261
+ mutating = false;
262
+ }
263
+ return token;
264
+ };
265
+ const publishBatch = (updates) => {
266
+ assertMutable();
267
+ if (!Array.isArray(updates)) {
268
+ throw new TypeError("coordination: publishBatch updates must be an array.");
269
+ }
270
+ const prepared = [];
271
+ const seen = new Set();
272
+ for (const update of updates) {
273
+ if (!isObject(update))
274
+ throw new TypeError("coordination: each update must be an object.");
275
+ const candidate = update;
276
+ if (seen.has(candidate.token))
277
+ fail("publishBatch contains a duplicate registration token.");
278
+ seen.add(candidate.token);
279
+ const registration = registrationFor(candidate.token);
280
+ const completed = copyCompleted(candidate.completed);
281
+ assertNoRegression(registration.completed, completed);
282
+ prepared.push({ registration, completed });
283
+ }
284
+ const changed = prepared.some(({ registration, completed }) => !registration.completed ||
285
+ !sameRevisions(registration.completed.revisions, completed.revisions));
286
+ if (!changed)
287
+ return snapshot;
288
+ const snapshotRevision = nextCounter(nextSnapshotRevision, "snapshot revision");
289
+ // Raw traits can expose individual mirror writes to Koota callbacks, but
290
+ // the committed projection changes only after every mirror write succeeds.
291
+ const preparedStates = prepared.map(({ registration, completed }) => ({
292
+ registration,
293
+ completed,
294
+ state: Object.freeze({
295
+ ...registration.state,
296
+ geometryRevision: completed.revisions.geometry,
297
+ poseRevision: completed.revisions.pose,
298
+ transformRevision: completed.revisions.transform,
299
+ }),
300
+ }));
301
+ mutating = true;
302
+ try {
303
+ for (const { registration, state } of preparedStates) {
304
+ registration.entity.set(CoordinationRegistration, state);
305
+ }
306
+ for (const { registration, completed } of preparedStates) {
307
+ registration.completed = completed;
308
+ }
309
+ return commit(snapshotRevision);
310
+ }
311
+ finally {
312
+ mutating = false;
313
+ }
314
+ };
315
+ const unregister = (token) => {
316
+ assertMutable();
317
+ const registration = registrationFor(token);
318
+ const snapshotRevision = nextCounter(nextSnapshotRevision, "snapshot revision");
319
+ mutating = true;
320
+ try {
321
+ registration.entity.remove(CoordinationRegistration);
322
+ registrationsById.delete(registration.id);
323
+ registrationsByToken.delete(token);
324
+ return commit(snapshotRevision);
325
+ }
326
+ finally {
327
+ mutating = false;
328
+ }
329
+ };
330
+ const prune = () => {
331
+ assertActive();
332
+ const stale = [...registrationsById.values()].filter((registration) => !isCurrent(registration));
333
+ if (stale.length === 0)
334
+ return snapshot;
335
+ const snapshotRevision = nextCounter(nextSnapshotRevision, "snapshot revision");
336
+ mutating = true;
337
+ try {
338
+ for (const registration of stale) {
339
+ registrationsById.delete(registration.id);
340
+ registrationsByToken.delete(registration.token);
341
+ }
342
+ return commit(snapshotRevision);
343
+ }
344
+ finally {
345
+ mutating = false;
346
+ }
347
+ };
348
+ const getEntity = (id) => {
349
+ const registration = registrationsById.get(id);
350
+ return registration && isCurrent(registration) ? registration.entity : undefined;
351
+ };
352
+ const getRegistration = (id) => {
353
+ const registration = registrationsById.get(id);
354
+ return registration && isCurrent(registration) ? registration.token : undefined;
355
+ };
356
+ const getSnapshot = () => snapshot;
357
+ const subscribe = (listener) => {
358
+ if (disposed)
359
+ fail("the binding has been disposed.");
360
+ if (typeof listener !== "function")
361
+ throw new TypeError("coordination: listener must be a function.");
362
+ listeners.add(listener);
363
+ return () => listeners.delete(listener);
364
+ };
365
+ const dispose = () => {
366
+ if (disposed)
367
+ return;
368
+ assertActive();
369
+ const snapshotRevision = nextCounter(nextSnapshotRevision, "snapshot revision");
370
+ mutating = true;
371
+ try {
372
+ listeners.clear();
373
+ for (const registration of registrationsById.values()) {
374
+ if (isCurrent(registration))
375
+ registration.entity.remove(CoordinationRegistration);
376
+ }
377
+ registrationsById.clear();
378
+ registrationsByToken.clear();
379
+ const markerState = world.get(CoordinationBinding);
380
+ if (world.isInitialized && markerState?.markers.includes(marker)) {
381
+ const remaining = markerState.markers.filter((candidate) => candidate !== marker);
382
+ if (remaining.length === 0)
383
+ world.remove(CoordinationBinding);
384
+ else
385
+ world.set(CoordinationBinding, { markers: remaining });
386
+ }
387
+ nextSnapshotRevision = snapshotRevision;
388
+ snapshot = makeSnapshot(nextSnapshotRevision, []);
389
+ disposed = true;
390
+ }
391
+ finally {
392
+ mutating = false;
393
+ }
394
+ };
395
+ return Object.freeze({
396
+ world,
397
+ register,
398
+ publish: (token, completed) => publishBatch([{ token, completed }]),
399
+ publishBatch,
400
+ unregister,
401
+ prune,
402
+ getRegistration,
403
+ getEntity,
404
+ getSnapshot,
405
+ subscribe,
406
+ dispose,
407
+ });
408
+ }
@@ -10,4 +10,6 @@ export { defineEventLog } from "./eventLog.js";
10
10
  export type { EventLog } from "./eventLog.js";
11
11
  export { defineTrait } from "./traits/index.js";
12
12
  export type { SafeSchema } from "./traits/index.js";
13
+ export { bindCoordination, CoordinationBinding, CoordinationRegistration, } from "./coordination.js";
14
+ export type { CompletedMetadata, CompletedRevisions, Coordination, CoordinationDescriptor, CoordinationEntry, CoordinationListener, CoordinationSnapshot, CoordinationUpdate, RegistrationToken, } from "./coordination.js";
13
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,iBAAiB,EACjB,kBAAkB,EAClB,YAAY,EACZ,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,mBAAmB,CAAC"}
package/dist/esm/index.js CHANGED
@@ -17,3 +17,4 @@ export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from ".
17
17
  export { createSchedule } from "./schedule.js";
18
18
  export { defineEventLog } from "./eventLog.js";
19
19
  export { defineTrait } from "./traits/index.js";
20
+ export { bindCoordination, CoordinationBinding, CoordinationRegistration, } from "./coordination.js";
package/docs/API.md CHANGED
@@ -251,6 +251,108 @@ scratch map. `drain` truncates the live array in place so an existing internal
251
251
  reference is not orphaned. Empty reads do not create a scratch entry. `peek`
252
252
  returns a detached array; `size` does not allocate.
253
253
 
254
+ ## `coordination`
255
+
256
+ Import from `koota-kit/coordination` or the root. This React-free entry binds
257
+ to an existing caller-owned Koota `World`; it never creates, resets, or
258
+ destroys that World or its entities.
259
+
260
+ ```ts
261
+ type CompletedRevisions = Readonly<{
262
+ geometry: number;
263
+ pose: number;
264
+ transform: number;
265
+ }>;
266
+
267
+ type CompletedMetadata = Readonly<{ revisions: CompletedRevisions }>;
268
+ type CoordinationDescriptor = Readonly<{
269
+ id: string;
270
+ capabilities: readonly string[];
271
+ }>;
272
+ type CoordinationUpdate = Readonly<{
273
+ token: RegistrationToken;
274
+ completed: CompletedMetadata;
275
+ }>;
276
+ type CoordinationEntry = Readonly<{
277
+ id: string;
278
+ capabilities: readonly string[];
279
+ completed: CompletedMetadata | undefined;
280
+ }>;
281
+ type CoordinationSnapshot = Readonly<{
282
+ revision: number;
283
+ entries: readonly CoordinationEntry[];
284
+ }>;
285
+
286
+ function bindCoordination(world: World): Coordination;
287
+ ```
288
+
289
+ `id` is a caller-authored, non-empty stable string. It is not a packed Koota
290
+ entity ID. Capability labels are copied, sorted, and must be unique non-empty
291
+ strings; change a capability set by unregistering and registering again.
292
+ `completed.revisions.geometry`, `.pose`, and `.transform` are all required
293
+ non-negative safe integers. They may advance independently but no dimension
294
+ may decrease. A new completed value with all three values equal is a no-op.
295
+
296
+ ```ts
297
+ type Coordination = Readonly<{
298
+ readonly world: World;
299
+ register(entity: Entity, descriptor: CoordinationDescriptor): RegistrationToken;
300
+ publish(token: RegistrationToken, completed: CompletedMetadata): CoordinationSnapshot;
301
+ publishBatch(updates: readonly CoordinationUpdate[]): CoordinationSnapshot;
302
+ unregister(token: RegistrationToken): CoordinationSnapshot;
303
+ prune(): CoordinationSnapshot;
304
+ getRegistration(id: string): RegistrationToken | undefined;
305
+ getEntity(id: string): Entity | undefined;
306
+ getSnapshot(): CoordinationSnapshot;
307
+ subscribe(listener: (snapshot: CoordinationSnapshot) => void): () => void;
308
+ dispose(): void;
309
+ }>;
310
+ ```
311
+
312
+ `register` accepts only a live entity in the supplied `World`; cross-World,
313
+ destroyed, duplicated, and externally conflicting entities are rejected before
314
+ the coordinator mutates its projection. The opaque token is scoped to one
315
+ binding and to the entity generation. `publish` and `unregister` reject a
316
+ stale token. If an entity is destroyed or the owned registration trait is
317
+ removed externally, call `prune()` to publish its removal before reusing the
318
+ stable ID. If Koota rejects the binding marker during construction or rejects an
319
+ add callback during `register`, the binding removes only its just-added marker or
320
+ trait before rethrowing, so an existing peer binding remains intact and the
321
+ caller may retry registration. `getEntity` and `getRegistration` return `undefined` for a stale
322
+ registration.
323
+
324
+ `publishBatch` validates every token and metadata value before it changes any
325
+ owned trait or snapshot. Its completed projection changes only after every raw
326
+ Koota trait write succeeds; a Koota callback error can leave partial raw mirror
327
+ writes, which are outside that projection. A completed snapshot is immutable,
328
+ detached from the caller input, and sorted by stable ID. Producers that need a
329
+ cross-producer atomic cohort must publish through the same binding; disjoint
330
+ bindings are independent cohorts. `revision` is a binding-local committed
331
+ publication generation, not a geometry, pose, transform, source-hash, or
332
+ provider freshness claim. It deliberately does not serialize entities or join
333
+ the RNG/clock `WorldSnapshot` boundary.
334
+
335
+ Subscribers each receive the same committed snapshot. A subscribe or
336
+ unsubscribe during delivery affects only a later publication. Registry mutation
337
+ during delivery or an owned Koota trait callback throws. Listener failures are
338
+ reported after every listener has been offered the committed snapshot; they do
339
+ not roll back it. When a listener throws during `register`, use
340
+ `getRegistration(id)` to recover the successfully committed token.
341
+
342
+ `dispose` is idempotent. It removes only this binding's registration traits and
343
+ World-held ownership marker, keeps other bindings' markers, and never destroys
344
+ or resets the borrowed World, its entities, caller traits, providers, or
345
+ buffers. Several bindings may share a World only for disjoint entities.
346
+
347
+ `CoordinationBinding` and `CoordinationRegistration` are the exported Koota
348
+ trait identities for this module format. The binding trait holds only opaque
349
+ per-binding ownership markers on the borrowed World; it is removed when the
350
+ last binding disposes. Direct trait mutation is outside the immutable committed-
351
+ snapshot contract; use the binding methods for publication. The module has no
352
+ renderer, React, producer, geometry, spatial-index, or AI dependency. Exact
353
+ adapters must own their own immutable buffers or read leases and report their
354
+ own source, topology, frame, unit, and freshness contracts.
355
+
254
356
  ## `react`
255
357
 
256
358
  Import from `koota-kit/react`. Requires `react` (`>=18`), an optional peer