@xnetjs/runtime 0.0.2 → 0.1.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.
- package/README.md +84 -1
- package/dist/index.d.ts +275 -2
- package/dist/index.js +426 -14
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -46,4 +46,87 @@ const decision = await client.auth.can({ action: 'write', nodeId: '…' })
|
|
|
46
46
|
await client.destroy()
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
## Use xNet from any framework
|
|
50
|
+
|
|
51
|
+
`client.query(schema, options)` returns the universal
|
|
52
|
+
`{ getSnapshot, subscribe }` contract — the exact pair React's
|
|
53
|
+
`useSyncExternalStore`, Vue's `shallowRef`, a Svelte store, a Solid signal, and
|
|
54
|
+
Angular's `toSignal` all bind to. `liveQuery()` already adapts it into the
|
|
55
|
+
**Svelte store contract**, so a binding for any framework is ~15–40 lines:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// Vue
|
|
59
|
+
import { shallowRef, onScopeDispose } from 'vue'
|
|
60
|
+
import { liveQuery } from '@xnetjs/runtime'
|
|
61
|
+
|
|
62
|
+
export function useQuery(client, schema, options) {
|
|
63
|
+
const lq = liveQuery(client, schema, options)
|
|
64
|
+
const data = shallowRef(lq.get())
|
|
65
|
+
const stop = lq.subscribe((v) => (data.value = v))
|
|
66
|
+
onScopeDispose(() => {
|
|
67
|
+
stop()
|
|
68
|
+
lq.destroy()
|
|
69
|
+
})
|
|
70
|
+
return data // Ref<NodeState[] | null>
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```svelte
|
|
75
|
+
<!-- Svelte: liveQuery IS a store, so $-auto-subscription just works -->
|
|
76
|
+
<script>
|
|
77
|
+
import { liveQuery } from '@xnetjs/runtime'
|
|
78
|
+
const tasks = liveQuery(client, TaskSchema, { where: { status: 'todo' } })
|
|
79
|
+
</script>
|
|
80
|
+
{#each $tasks ?? [] as task}<li>{task.properties.title}</li>{/each}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// Vanilla — no framework, no adapter package
|
|
85
|
+
const tasks = liveQuery(client, TaskSchema)
|
|
86
|
+
const stop = tasks.subscribe((rows) => render(rows ?? []))
|
|
87
|
+
await client.mutate.create(TaskSchema, { title: 'Ship it' }) // re-renders
|
|
88
|
+
// later: stop(); tasks.destroy(); await client.destroy()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Validate a binding with `runAdapterConformance`
|
|
92
|
+
|
|
93
|
+
Behaviour is tested **once**, framework-agnostically. Any adapter — or your own
|
|
94
|
+
app — can run the same contract against its client factory:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { runAdapterConformance } from '@xnetjs/runtime'
|
|
98
|
+
import { MemoryNodeStorageAdapter } from '@xnetjs/data'
|
|
99
|
+
|
|
100
|
+
// Throws on the first failed check; resolves with the result otherwise.
|
|
101
|
+
await runAdapterConformance((overrides) =>
|
|
102
|
+
createXNetClient({
|
|
103
|
+
nodeStorage: new MemoryNodeStorageAdapter(),
|
|
104
|
+
authorDID,
|
|
105
|
+
signingKey,
|
|
106
|
+
...overrides
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
It asserts the reactive data contract: an immediate live-query snapshot then
|
|
112
|
+
updates on `mutate`, no delivery after unsubscribe, one-shot `fetch` round-trips,
|
|
113
|
+
the authorization surface is reachable and **denial surfaces**, and `destroy()`
|
|
114
|
+
is idempotent.
|
|
115
|
+
|
|
116
|
+
## Support tiers (what "supported" means)
|
|
117
|
+
|
|
118
|
+
Per [exploration 0237](../../docs/explorations), framework support is layered —
|
|
119
|
+
binding the data layer is cheap; carrying a full per-framework component
|
|
120
|
+
ecosystem is not. The committed levels:
|
|
121
|
+
|
|
122
|
+
| Tier | Surface | Scope |
|
|
123
|
+
| ---------- | ---------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
124
|
+
| **Tier 0** | `@xnetjs/runtime` — `createXNetClient` + `liveQuery` | **every** framework, headless; conformance-gated. This page. |
|
|
125
|
+
| **Tier 1** | `@xnetjs/react` — hooks **and** components | first-class; the app dogfoods it. |
|
|
126
|
+
| **Tier 2** | thin Vue / Svelte data-binding adapters | `useQuery`/`useMutate` only — **no components** — published on demand. |
|
|
127
|
+
| **—** | components in non-React frameworks | **not offered** (`@xnetjs/ui` is React-only by design). |
|
|
128
|
+
|
|
129
|
+
All UI components are React. Other frameworks consume the headless runtime.
|
|
130
|
+
|
|
131
|
+
See `docs/explorations/0185_*` (runtime extraction) and
|
|
132
|
+
`docs/explorations/0237_*` (support-tier policy) for the design rationale.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
2
|
import { ContentId, DID, PolicyEvaluator, AuthCheckInput, AuthDecision } from '@xnetjs/core';
|
|
3
3
|
import { NodeStore, NodeStorageAdapter, NodeContentCipher, StoreAuthAPI, SchemaLookup, LensRegistry, PropertyBuilder, DefinedSchema, NodeState, TransactionOperation, NodeChange, NodePayload } from '@xnetjs/data';
|
|
4
|
-
import { SyncLifecycleState, SyncReplicationConfig, ChangeSigner } from '@xnetjs/sync';
|
|
4
|
+
import { SyncLifecycleState, SyncReplicationConfig, ChangeSigner, ReplicationPlan } from '@xnetjs/sync';
|
|
5
5
|
export { SyncLifecyclePhase, SyncLifecycleState } from '@xnetjs/sync';
|
|
6
6
|
import { Awareness } from 'y-protocols/awareness';
|
|
7
7
|
import { DataBridge, MainThreadBridgeOptions, SyncStatus as SyncStatus$1, QueryOptions, BridgeTransactionResult, AcquiredDoc } from '@xnetjs/data-bridge';
|
|
@@ -476,6 +476,77 @@ interface LiveQuery {
|
|
|
476
476
|
*/
|
|
477
477
|
declare function liveQuery<P extends Record<string, PropertyBuilder>>(client: XNetClient, schema: DefinedSchema<P>, options?: QueryOptions<P>): LiveQuery;
|
|
478
478
|
|
|
479
|
+
/**
|
|
480
|
+
* runAdapterConformance — the executable "use xNet from any framework" contract.
|
|
481
|
+
*
|
|
482
|
+
* Exploration 0237 argues that the costly part of multi-framework support is not
|
|
483
|
+
* *writing* a Vue/Svelte/Solid binding (each is ~40 lines over {@link liveQuery})
|
|
484
|
+
* but *validating* that every binding behaves identically forever. The answer is
|
|
485
|
+
* to test the behaviour **once**, framework-agnostically, and have each adapter
|
|
486
|
+
* run only a tiny render-harness check on top.
|
|
487
|
+
*
|
|
488
|
+
* This is that once. Given a `makeClient` factory, it asserts the reactive data
|
|
489
|
+
* contract every adapter depends on:
|
|
490
|
+
*
|
|
491
|
+
* 1. live query delivers an immediate snapshot, then updates on `mutate`
|
|
492
|
+
* 2. unsubscribing stops delivery
|
|
493
|
+
* 3. one-shot `fetch` round-trips a write
|
|
494
|
+
* 4. the authorization surface is reachable and **denial surfaces**
|
|
495
|
+
* 5. `destroy()` is idempotent
|
|
496
|
+
*
|
|
497
|
+
* It imports no test framework and no UI framework, so it runs in vitest, a
|
|
498
|
+
* browser test runner, or a plain Node script. It **throws** on the first failed
|
|
499
|
+
* check (with the full check list attached) and otherwise resolves with the
|
|
500
|
+
* result — so `await runAdapterConformance(makeClient)` *is* the assertion.
|
|
501
|
+
*/
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Factory for the client under test. Accepts the same overrides
|
|
505
|
+
* {@link CreateXNetClientOptions} so the suite can, for example, inject a
|
|
506
|
+
* deny-writes evaluator for the authorization check. Adapter authors typically
|
|
507
|
+
* pass a one-liner that spreads `overrides` into `createXNetClient`.
|
|
508
|
+
*/
|
|
509
|
+
type ConformanceClientFactory = (overrides?: Partial<CreateXNetClientOptions>) => Promise<XNetClient>;
|
|
510
|
+
/** Outcome of a single contract check. */
|
|
511
|
+
interface AdapterConformanceCheck {
|
|
512
|
+
name: string;
|
|
513
|
+
passed: boolean;
|
|
514
|
+
detail?: string;
|
|
515
|
+
}
|
|
516
|
+
/** Aggregate result of a conformance run. */
|
|
517
|
+
interface AdapterConformanceResult {
|
|
518
|
+
passed: boolean;
|
|
519
|
+
checks: AdapterConformanceCheck[];
|
|
520
|
+
}
|
|
521
|
+
/** Thrown when one or more checks fail; carries the full check list. */
|
|
522
|
+
declare class AdapterConformanceError extends Error {
|
|
523
|
+
readonly checks: AdapterConformanceCheck[];
|
|
524
|
+
constructor(checks: AdapterConformanceCheck[]);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Run the full adapter-conformance suite against a client factory.
|
|
528
|
+
*
|
|
529
|
+
* Resolves with the {@link AdapterConformanceResult} when every check passes,
|
|
530
|
+
* and throws {@link AdapterConformanceError} (with the full check list) on the
|
|
531
|
+
* first failure — so the call itself is the assertion.
|
|
532
|
+
*
|
|
533
|
+
* @example
|
|
534
|
+
* ```ts
|
|
535
|
+
* import { runAdapterConformance } from '@xnetjs/runtime'
|
|
536
|
+
* import { MemoryNodeStorageAdapter } from '@xnetjs/data'
|
|
537
|
+
*
|
|
538
|
+
* await runAdapterConformance((overrides) =>
|
|
539
|
+
* createXNetClient({
|
|
540
|
+
* nodeStorage: new MemoryNodeStorageAdapter(),
|
|
541
|
+
* authorDID,
|
|
542
|
+
* signingKey,
|
|
543
|
+
* ...overrides
|
|
544
|
+
* })
|
|
545
|
+
* )
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
declare function runAdapterConformance(makeClient: ConformanceClientFactory): Promise<AdapterConformanceResult>;
|
|
549
|
+
|
|
479
550
|
/**
|
|
480
551
|
* WebSocketSyncProvider - Syncs Y.Doc via WebSocket relay
|
|
481
552
|
*
|
|
@@ -640,6 +711,21 @@ interface NodePoolConfig {
|
|
|
640
711
|
onDocUpdate?: (nodeId: string, doc: Y.Doc) => void;
|
|
641
712
|
/** Optional callback before a doc is evicted */
|
|
642
713
|
onDocEvict?: (nodeId: string, doc: Y.Doc) => void;
|
|
714
|
+
/**
|
|
715
|
+
* Predicate marking a node's Y.Doc as **ephemeral** — never persisted to
|
|
716
|
+
* `yjs_state` and never cold-loaded from it. Workspace presence is the
|
|
717
|
+
* canonical case: it is republished continuously and has no value across
|
|
718
|
+
* reloads, yet (as a `gc:false` doc, written on every awareness tick) its
|
|
719
|
+
* persisted blob grows unboundedly and its cold read at boot head-of-line
|
|
720
|
+
* blocked every landing query on the single SQLite worker (exploration 0227).
|
|
721
|
+
* Defaults to the `presence-` id prefix.
|
|
722
|
+
*/
|
|
723
|
+
isEphemeral?: (nodeId: string) => boolean;
|
|
724
|
+
/**
|
|
725
|
+
* Warn (once per load) when a `yjs_state` blob read or persisted exceeds this
|
|
726
|
+
* many bytes — a tripwire for unbounded `gc:false` growth. Default 5 MiB.
|
|
727
|
+
*/
|
|
728
|
+
largeDocWarnBytes?: number;
|
|
643
729
|
}
|
|
644
730
|
interface NodePool {
|
|
645
731
|
/** Acquire a Y.Doc for a Node (load from storage or create new) */
|
|
@@ -659,6 +745,193 @@ interface NodePool {
|
|
|
659
745
|
}
|
|
660
746
|
declare function createNodePool(config: NodePoolConfig): NodePool;
|
|
661
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Replication scope — the Space→namespace shim that lets the (already built)
|
|
750
|
+
* `planReplicationDestinations` router key on something the runtime actually
|
|
751
|
+
* addresses (exploration 0258).
|
|
752
|
+
*
|
|
753
|
+
* The routing planner in `@xnetjs/sync` speaks in *namespaces* (strings like
|
|
754
|
+
* `xnet://<did>/space/<id>/`), but the runtime addresses per-node rooms
|
|
755
|
+
* (`xnet-doc-<nodeId>`). Nothing mapped one to the other, so the planner sat
|
|
756
|
+
* exported-but-unconsumed. A **Space** is the natural bridge: it is already the
|
|
757
|
+
* security boundary, every content node carries a single `space` relation, and
|
|
758
|
+
* a human reasons about "where does *this Space* live?". So the Space is the
|
|
759
|
+
* unit of replication, and its namespace is the routing key.
|
|
760
|
+
*
|
|
761
|
+
* This module also carries the "manifest as data" bridge:
|
|
762
|
+
* `replicationConfigFromPolicies` turns a set of per-Space replication policies
|
|
763
|
+
* (which can themselves be synced nodes) into the `SyncReplicationConfig` the
|
|
764
|
+
* planner consumes — so *editing where a Space lives* is ordinary data, not
|
|
765
|
+
* device-local config.
|
|
766
|
+
*/
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Trust class of a replication destination. A `trusted` hub holds plaintext and
|
|
770
|
+
* can index/search/serve; a `zero-knowledge` hub holds only recipient-scoped
|
|
771
|
+
* ciphertext and can relay but not read. Carried on the manifest shape here;
|
|
772
|
+
* the plaintext-vs-ciphertext *gate* is a later phase (0258) and not yet
|
|
773
|
+
* enforced.
|
|
774
|
+
*/
|
|
775
|
+
type ReplicaTrust = 'trusted' | 'zero-knowledge';
|
|
776
|
+
/** The namespace for a Space's content — the routing key for the planner. */
|
|
777
|
+
declare function spaceNamespace(ownerDID: string, spaceId: string): string;
|
|
778
|
+
/**
|
|
779
|
+
* The namespace for a user's system data (schemas, authz, the manifest itself).
|
|
780
|
+
* Classified `system` by the planner (the `sys/` segment), so it can route to
|
|
781
|
+
* `defaultSystemHubIds` and be replicated everywhere for bootstrap.
|
|
782
|
+
*/
|
|
783
|
+
declare function systemNamespace(ownerDID: string): string;
|
|
784
|
+
/** Minimal node shape the scope mapping needs. */
|
|
785
|
+
interface ReplicationScopeNode {
|
|
786
|
+
id: string;
|
|
787
|
+
/** The Space this node's security lives in, if any. */
|
|
788
|
+
space?: string | null;
|
|
789
|
+
/** Author DID; scopes the namespace when no explicit owner is given. */
|
|
790
|
+
createdBy?: string | null;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The replication namespace for a node: its Space's namespace when it has one,
|
|
794
|
+
* otherwise a self-scoped namespace under its owner (so an unfiled node still
|
|
795
|
+
* has a stable, owner-scoped routing key).
|
|
796
|
+
*/
|
|
797
|
+
declare function namespaceForNode(node: ReplicationScopeNode, fallbackOwnerDID?: string): string;
|
|
798
|
+
/** One destination in a Space's replication manifest. */
|
|
799
|
+
interface ReplicationDestinationSpec {
|
|
800
|
+
/** Stable hub id used by routing policies. */
|
|
801
|
+
hubId: string;
|
|
802
|
+
/** WebSocket URL for the hub. */
|
|
803
|
+
url: string;
|
|
804
|
+
/** Lower is preferred when a `maxHubs` cap prunes a plan. */
|
|
805
|
+
priority?: number;
|
|
806
|
+
/**
|
|
807
|
+
* Trust class of this destination. Reserved for the plaintext vs
|
|
808
|
+
* zero-knowledge replication gate (0258); recorded on the manifest but not
|
|
809
|
+
* yet enforced.
|
|
810
|
+
*/
|
|
811
|
+
trust?: ReplicaTrust;
|
|
812
|
+
/** Minimum replica count this Space wants (feeds the planner's `minHubs`). */
|
|
813
|
+
minReplicas?: number;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* A per-Space replication policy — the "manifest of what goes where" as data.
|
|
817
|
+
* A group of these can be persisted as synced nodes; this module turns them
|
|
818
|
+
* into the config the planner consumes.
|
|
819
|
+
*/
|
|
820
|
+
interface SpaceReplicationPolicy {
|
|
821
|
+
/** The Space id (the replication scope). */
|
|
822
|
+
space: string;
|
|
823
|
+
/** Owner DID that scopes the Space's namespace. */
|
|
824
|
+
ownerDID: string;
|
|
825
|
+
/** Where this Space replicates. */
|
|
826
|
+
destinations: readonly ReplicationDestinationSpec[];
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Build a `SyncReplicationConfig` from a set of per-Space replication policies.
|
|
830
|
+
*
|
|
831
|
+
* Each policy contributes:
|
|
832
|
+
* - its destinations to a shared hub inventory (deduped by `hubId`), and
|
|
833
|
+
* - a namespace policy for `spaceNamespace(ownerDID, space)` that includes
|
|
834
|
+
* exactly those hubs, with `minHubs` set to the largest `minReplicas` any of
|
|
835
|
+
* its destinations asks for.
|
|
836
|
+
*
|
|
837
|
+
* The result is a *pure function of the manifest*: change a policy and the plan
|
|
838
|
+
* changes deterministically, which is what makes "manifest as data" work.
|
|
839
|
+
*/
|
|
840
|
+
declare function replicationConfigFromPolicies(policies: readonly SpaceReplicationPolicy[]): SyncReplicationConfig;
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* MultiHubSyncManager — the selective-routing brain for multi-home sync
|
|
844
|
+
* (exploration 0258).
|
|
845
|
+
*
|
|
846
|
+
* The pieces for multiplayer already existed but weren't joined up:
|
|
847
|
+
* - `@xnetjs/sync`'s `planReplicationDestinations` decides *which hubs* a
|
|
848
|
+
* namespace should replicate to — but nothing called it.
|
|
849
|
+
* - `createMultiHubConnectionManager` already fans a *multiplexed* connection
|
|
850
|
+
* (O(1) sockets per hub, not per doc) out to N hubs — but it publishes to
|
|
851
|
+
* *every* hub unconditionally, with no per-scope selection.
|
|
852
|
+
*
|
|
853
|
+
* This manager sits between them: given a namespace (a Space's, via
|
|
854
|
+
* `replication-scope`), it consults the planner and joins/publishes a room on
|
|
855
|
+
* *only the hubs the policy selects*. With no policy it defaults to a full
|
|
856
|
+
* mirror (every configured hub), which is the safe local-first default —
|
|
857
|
+
* "back up everything to all your hubs" — and selective routing kicks in the
|
|
858
|
+
* moment a namespace policy is present.
|
|
859
|
+
*
|
|
860
|
+
* Because it routes over the existing multiplexed transports rather than
|
|
861
|
+
* opening a socket per (doc × hub), it does not reintroduce the connection
|
|
862
|
+
* explosion the exploration warned about.
|
|
863
|
+
*
|
|
864
|
+
* The transports are injected (any object with join/publish/connect), so the
|
|
865
|
+
* routing logic is unit-testable without real WebSockets, and the real caller
|
|
866
|
+
* passes `createConnectionManager(...)` per hub.
|
|
867
|
+
*/
|
|
868
|
+
|
|
869
|
+
/** The slice of a hub connection this manager drives (a `ConnectionManager` fits). */
|
|
870
|
+
interface HubTransport {
|
|
871
|
+
connect(): void;
|
|
872
|
+
disconnect(): void;
|
|
873
|
+
/** Subscribe to a room; returns an unsubscribe function. */
|
|
874
|
+
joinRoom(room: string, handler: (data: Record<string, unknown>) => void): () => void;
|
|
875
|
+
/** Publish a message to a room. */
|
|
876
|
+
publish(room: string, data: object): void;
|
|
877
|
+
}
|
|
878
|
+
/** One hub the manager can route to. */
|
|
879
|
+
interface HubConnection {
|
|
880
|
+
/** Stable hub id referenced by routing policies. */
|
|
881
|
+
hubId: string;
|
|
882
|
+
/** WebSocket URL (also used to overlay any policy hub entry by id). */
|
|
883
|
+
url: string;
|
|
884
|
+
/** The multiplexed transport for this hub. */
|
|
885
|
+
transport: HubTransport;
|
|
886
|
+
/**
|
|
887
|
+
* Trust class of this hub. Reserved for the plaintext vs zero-knowledge gate
|
|
888
|
+
* (0258); surfaced on `plannedHubs` but not yet enforced.
|
|
889
|
+
*/
|
|
890
|
+
trust?: ReplicaTrust;
|
|
891
|
+
}
|
|
892
|
+
interface MultiHubSyncManagerConfig {
|
|
893
|
+
/** One multiplexed connection per hub. */
|
|
894
|
+
hubs: readonly HubConnection[];
|
|
895
|
+
/** Routing policy — the `federation` half is finally consulted here. */
|
|
896
|
+
replication?: SyncReplicationConfig;
|
|
897
|
+
/** Map a node id to its wire room. Default: `xnet-doc-<nodeId>`. */
|
|
898
|
+
roomForNode?: (nodeId: string) => string;
|
|
899
|
+
}
|
|
900
|
+
/** A planned destination that the manager actually has a transport for. */
|
|
901
|
+
interface PlannedHub {
|
|
902
|
+
hubId: string;
|
|
903
|
+
url: string;
|
|
904
|
+
trust: ReplicaTrust | undefined;
|
|
905
|
+
}
|
|
906
|
+
/** Handle for a room joined on the policy-selected hubs. */
|
|
907
|
+
interface ScopedRoomHandle {
|
|
908
|
+
/** The plan that selected these hubs (carries diagnostics + trace). */
|
|
909
|
+
readonly plan: ReplicationPlan;
|
|
910
|
+
/** Hub ids this room is currently subscribed on. */
|
|
911
|
+
readonly hubIds: readonly string[];
|
|
912
|
+
/** Leave the room on every hub it was joined on. */
|
|
913
|
+
leave(): void;
|
|
914
|
+
}
|
|
915
|
+
interface MultiHubSyncManager {
|
|
916
|
+
/** The routing plan for a namespace (all destinations, incl. unknown hubs). */
|
|
917
|
+
planFor(namespace: string): ReplicationPlan;
|
|
918
|
+
/** Planned destinations the manager has a transport for. */
|
|
919
|
+
plannedHubs(namespace: string): PlannedHub[];
|
|
920
|
+
/** Hub ids a namespace routes to (and that we can reach). */
|
|
921
|
+
destinationsFor(namespace: string): string[];
|
|
922
|
+
/** Join a node's room on exactly the hubs the policy selects. */
|
|
923
|
+
joinScopedRoom(nodeId: string, namespace: string, handler: (data: Record<string, unknown>) => void): ScopedRoomHandle;
|
|
924
|
+
/** Publish to a room on exactly the hubs the namespace routes to. */
|
|
925
|
+
publishScoped(namespace: string, room: string, data: object): void;
|
|
926
|
+
/** Replace the routing policy; live rooms re-route to match (manifest-as-data). */
|
|
927
|
+
setReplication(replication: SyncReplicationConfig | undefined): void;
|
|
928
|
+
/** Connect every hub transport. */
|
|
929
|
+
connect(): void;
|
|
930
|
+
/** Leave every room and disconnect every hub transport. */
|
|
931
|
+
disconnect(): void;
|
|
932
|
+
}
|
|
933
|
+
declare function createMultiHubSyncManager(config: MultiHubSyncManagerConfig): MultiHubSyncManager;
|
|
934
|
+
|
|
662
935
|
/**
|
|
663
936
|
* NodeStoreSyncProvider - Sync NodeChange events via hub ConnectionManager.
|
|
664
937
|
*
|
|
@@ -1017,4 +1290,4 @@ declare function negotiateProtocolVersion(ours: readonly string[], theirs: reado
|
|
|
1017
1290
|
*/
|
|
1018
1291
|
declare function isProtocolCompatible(theirs: readonly string[]): boolean;
|
|
1019
1292
|
|
|
1020
|
-
export { type BlobStoreForSync, type ConnectionManager, type ConnectionManagerConfig, type ConnectionStatus, type CreateXNetClientOptions, type InitialSyncManager, type InitialSyncMessage, type LiveQuery, type LiveQueryValue, METABRIDGE_ORIGIN, METABRIDGE_SEED_ORIGIN, type MetaBridge, type MultiHubConnectionManagerConfig, type NodePool, type NodePoolConfig, NodeStoreSyncProvider, type NodeSyncResponse, type OfflineQueue, type OfflineQueueConfig, type PoolEntryState, type ProgressListener, type QueueEntry, type Registry, type RegistryConfig, type RegistryStorage, type SerializedNodeChange, type SyncManager, type SyncManagerConfig, type SyncPhase, type SyncProgress, type SyncReconciliationOptions, type SyncReconciliationReport, type SyncStatus, type TrackedNode, WebSocketSyncProvider, type WebSocketSyncProviderOptions, XNET_AWARENESS_VERSION, XNET_DATA_MODEL_VERSION, XNET_PROTOCOL_VERSION, XNET_SCHEMA_VERSION, XNET_SUPPORTED_PROTOCOL_VERSIONS, XNET_SYNC_ENVELOPE_VERSION, XNET_UCAN_PROFILE, type XNetClient, type XNetClientBridgeMode, type XNetClientPluginOptions, type XNetClientRuntimePhase, type XNetClientRuntimeStatus, type XNetClientSyncOptions, type XNetClientTelemetry, type XNetClientUndoOptions, type XNetProtocolBundle, createConnectionManager, createInitialSyncManager, createMetaBridge, createMultiHubConnectionManager, createNodePool, createOfflineQueue, createRegistry, createSyncManager, createXNetClient, isProtocolCompatible, liveQuery, negotiateProtocolVersion };
|
|
1293
|
+
export { type AdapterConformanceCheck, AdapterConformanceError, type AdapterConformanceResult, type BlobStoreForSync, type ConformanceClientFactory, type ConnectionManager, type ConnectionManagerConfig, type ConnectionStatus, type CreateXNetClientOptions, type HubConnection, type HubTransport, type InitialSyncManager, type InitialSyncMessage, type LiveQuery, type LiveQueryValue, METABRIDGE_ORIGIN, METABRIDGE_SEED_ORIGIN, type MetaBridge, type MultiHubConnectionManagerConfig, type MultiHubSyncManager, type MultiHubSyncManagerConfig, type NodePool, type NodePoolConfig, NodeStoreSyncProvider, type NodeSyncResponse, type OfflineQueue, type OfflineQueueConfig, type PlannedHub, type PoolEntryState, type ProgressListener, type QueueEntry, type Registry, type RegistryConfig, type RegistryStorage, type ReplicaTrust, type ReplicationDestinationSpec, type ReplicationScopeNode, type ScopedRoomHandle, type SerializedNodeChange, type SpaceReplicationPolicy, type SyncManager, type SyncManagerConfig, type SyncPhase, type SyncProgress, type SyncReconciliationOptions, type SyncReconciliationReport, type SyncStatus, type TrackedNode, WebSocketSyncProvider, type WebSocketSyncProviderOptions, XNET_AWARENESS_VERSION, XNET_DATA_MODEL_VERSION, XNET_PROTOCOL_VERSION, XNET_SCHEMA_VERSION, XNET_SUPPORTED_PROTOCOL_VERSIONS, XNET_SYNC_ENVELOPE_VERSION, XNET_UCAN_PROFILE, type XNetClient, type XNetClientBridgeMode, type XNetClientPluginOptions, type XNetClientRuntimePhase, type XNetClientRuntimeStatus, type XNetClientSyncOptions, type XNetClientTelemetry, type XNetClientUndoOptions, type XNetProtocolBundle, createConnectionManager, createInitialSyncManager, createMetaBridge, createMultiHubConnectionManager, createMultiHubSyncManager, createNodePool, createOfflineQueue, createRegistry, createSyncManager, createXNetClient, isProtocolCompatible, liveQuery, namespaceForNode, negotiateProtocolVersion, replicationConfigFromPolicies, runAdapterConformance, spaceNamespace, systemNamespace };
|
package/dist/index.js
CHANGED
|
@@ -697,20 +697,62 @@ function createMetaBridge(store, options) {
|
|
|
697
697
|
|
|
698
698
|
// src/sync/node-pool.ts
|
|
699
699
|
import * as Y from "yjs";
|
|
700
|
+
var DEFAULT_LARGE_DOC_WARN_BYTES = 5 * 1024 * 1024;
|
|
701
|
+
function defaultIsEphemeral(nodeId) {
|
|
702
|
+
return nodeId.startsWith("presence-");
|
|
703
|
+
}
|
|
704
|
+
function nowMs() {
|
|
705
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
706
|
+
}
|
|
707
|
+
function bootDebugEnabled() {
|
|
708
|
+
try {
|
|
709
|
+
return typeof localStorage !== "undefined" && localStorage.getItem("xnet:boot:debug") === "true";
|
|
710
|
+
} catch {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
700
714
|
function createNodePool(config) {
|
|
701
715
|
const entries = /* @__PURE__ */ new Map();
|
|
702
716
|
const persistTimers = /* @__PURE__ */ new Map();
|
|
703
717
|
const maxWarm = config.maxWarm ?? 50;
|
|
704
718
|
const persistDelay = config.persistDelay ?? 2e3;
|
|
719
|
+
const isEphemeral = config.isEphemeral ?? defaultIsEphemeral;
|
|
720
|
+
const largeDocWarnBytes = config.largeDocWarnBytes ?? DEFAULT_LARGE_DOC_WARN_BYTES;
|
|
721
|
+
let firstAcquireMarked = false;
|
|
722
|
+
function markFirstAcquire() {
|
|
723
|
+
if (firstAcquireMarked) return;
|
|
724
|
+
firstAcquireMarked = true;
|
|
725
|
+
try {
|
|
726
|
+
performance?.mark?.("xnet:docpool:first-acquire");
|
|
727
|
+
} catch {
|
|
728
|
+
}
|
|
729
|
+
}
|
|
705
730
|
async function loadDoc(nodeId) {
|
|
706
731
|
const doc = new Y.Doc({ guid: nodeId, gc: false });
|
|
732
|
+
if (isEphemeral(nodeId)) return doc;
|
|
733
|
+
const t0 = nowMs();
|
|
707
734
|
const content = await config.storage.getDocumentContent(nodeId);
|
|
735
|
+
const tRead = nowMs();
|
|
708
736
|
if (content && content.length > 0) {
|
|
709
737
|
Y.applyUpdate(doc, content);
|
|
738
|
+
const tApply = nowMs();
|
|
739
|
+
if (content.length >= largeDocWarnBytes) {
|
|
740
|
+
console.warn(
|
|
741
|
+
`[NodePool] large yjs_state blob for ${nodeId}: ${content.length} bytes \u2014 consider compacting (gc:false retains tombstones)`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (bootDebugEnabled()) {
|
|
745
|
+
console.info("[xNet] loadDoc", nodeId, {
|
|
746
|
+
bytes: content.length,
|
|
747
|
+
readMs: Math.round(tRead - t0),
|
|
748
|
+
applyMs: Math.round(tApply - tRead)
|
|
749
|
+
});
|
|
750
|
+
}
|
|
710
751
|
}
|
|
711
752
|
return doc;
|
|
712
753
|
}
|
|
713
754
|
function schedulePersist(nodeId) {
|
|
755
|
+
if (isEphemeral(nodeId)) return;
|
|
714
756
|
const existing = persistTimers.get(nodeId);
|
|
715
757
|
if (existing) clearTimeout(existing);
|
|
716
758
|
persistTimers.set(
|
|
@@ -741,8 +783,15 @@ function createNodePool(config) {
|
|
|
741
783
|
await Promise.all(
|
|
742
784
|
toEvict.map(async ([id, entry]) => {
|
|
743
785
|
try {
|
|
744
|
-
|
|
745
|
-
|
|
786
|
+
if (!isEphemeral(id)) {
|
|
787
|
+
const content = Y.encodeStateAsUpdate(entry.doc);
|
|
788
|
+
if (content.length >= largeDocWarnBytes) {
|
|
789
|
+
console.warn(
|
|
790
|
+
`[NodePool] large yjs_state blob for ${id}: ${content.length} bytes \u2014 consider compacting (gc:false retains tombstones)`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
await config.storage.setDocumentContent(id, content);
|
|
794
|
+
}
|
|
746
795
|
} catch (err) {
|
|
747
796
|
console.error(`[NodePool] Failed to persist document ${id} during eviction:`, err);
|
|
748
797
|
}
|
|
@@ -762,9 +811,11 @@ function createNodePool(config) {
|
|
|
762
811
|
entry.refCount++;
|
|
763
812
|
entry.state = "active";
|
|
764
813
|
entry.lastAccess = Date.now();
|
|
814
|
+
markFirstAcquire();
|
|
765
815
|
return entry.doc;
|
|
766
816
|
}
|
|
767
817
|
const doc = await loadDoc(nodeId);
|
|
818
|
+
markFirstAcquire();
|
|
768
819
|
doc.on("update", () => {
|
|
769
820
|
const e = entries.get(nodeId);
|
|
770
821
|
if (e) {
|
|
@@ -811,7 +862,7 @@ function createNodePool(config) {
|
|
|
811
862
|
persistTimers.clear();
|
|
812
863
|
const promises = [];
|
|
813
864
|
for (const [id, entry] of entries) {
|
|
814
|
-
if (entry.dirty) {
|
|
865
|
+
if (entry.dirty && !isEphemeral(id)) {
|
|
815
866
|
const content = Y.encodeStateAsUpdate(entry.doc);
|
|
816
867
|
promises.push(
|
|
817
868
|
config.storage.setDocumentContent(id, content).then(() => {
|
|
@@ -841,6 +892,15 @@ var SEND_WINDOW_MS = 1e3;
|
|
|
841
892
|
var SYNC_RESPONSE_TIMEOUT_MS = 4e3;
|
|
842
893
|
var STRUCTURAL_REJECTION_CODES = /* @__PURE__ */ new Set(["INVALID_HASH", "INVALID_SIGNATURE", "INVALID_CHANGE"]);
|
|
843
894
|
var MAX_STRUCTURAL_REJECTIONS = 5;
|
|
895
|
+
var OUTBOUND_ENQUEUE_BATCH = 1024;
|
|
896
|
+
var HEAVY_RESYNC_CHANGES = 5e3;
|
|
897
|
+
var HEAVY_RESYNC_MS = 250;
|
|
898
|
+
function nowMs2() {
|
|
899
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
900
|
+
}
|
|
901
|
+
function yieldToEventLoop() {
|
|
902
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
903
|
+
}
|
|
844
904
|
var NodeStoreSyncProvider = class {
|
|
845
905
|
constructor(store, room) {
|
|
846
906
|
this.store = store;
|
|
@@ -1119,6 +1179,13 @@ var NodeStoreSyncProvider = class {
|
|
|
1119
1179
|
await this.store.applyRemoteChanges(knownChanges);
|
|
1120
1180
|
}
|
|
1121
1181
|
}
|
|
1182
|
+
if (response.highWaterMark > 0 && !this.outboundHalted && response.highWaterMark < this.lastSyncedLamport && this.pushedThrough > response.highWaterMark) {
|
|
1183
|
+
console.warn(
|
|
1184
|
+
`[NodeStoreSync] hub high-water mark ${response.highWaterMark} is below the confirmed cursor ${this.lastSyncedLamport} (hub rollback?); re-offering local changes`
|
|
1185
|
+
);
|
|
1186
|
+
this.pushedThrough = response.highWaterMark;
|
|
1187
|
+
void this.syncLocalChanges();
|
|
1188
|
+
}
|
|
1122
1189
|
if (response.highWaterMark > this.lastSyncedLamport) {
|
|
1123
1190
|
this.lastSyncedLamport = response.highWaterMark;
|
|
1124
1191
|
this.pushedThrough = Math.max(this.pushedThrough, this.lastSyncedLamport);
|
|
@@ -1136,11 +1203,25 @@ var NodeStoreSyncProvider = class {
|
|
|
1136
1203
|
async syncLocalChanges() {
|
|
1137
1204
|
if (this.outboundHalted) return;
|
|
1138
1205
|
if (!this.connection || this.connection.status !== "connected") return;
|
|
1206
|
+
const t0 = nowMs2();
|
|
1139
1207
|
const changes = await this.store.getChangesSince(this.pushedThrough);
|
|
1140
1208
|
if (changes.length === 0) return;
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1209
|
+
const fetchMs = nowMs2() - t0;
|
|
1210
|
+
changes.sort(
|
|
1211
|
+
(a, b) => a.lamport - b.lamport || (a.authorDID < b.authorDID ? -1 : a.authorDID > b.authorDID ? 1 : 0)
|
|
1212
|
+
);
|
|
1213
|
+
const sortMs = nowMs2() - t0 - fetchMs;
|
|
1214
|
+
for (let i = 0; i < changes.length; i++) {
|
|
1215
|
+
this.enqueueChange(changes[i]);
|
|
1216
|
+
if ((i + 1) % OUTBOUND_ENQUEUE_BATCH === 0 && i + 1 < changes.length) {
|
|
1217
|
+
if (this.outboundHalted || this.connection?.status !== "connected") return;
|
|
1218
|
+
await yieldToEventLoop();
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (changes.length >= HEAVY_RESYNC_CHANGES || fetchMs + sortMs >= HEAVY_RESYNC_MS) {
|
|
1222
|
+
console.warn(
|
|
1223
|
+
`[NodeStoreSync] heavy outbound resync: ${changes.length} changes, fetch+deserialize ${Math.round(fetchMs)}ms, sort ${Math.round(sortMs)}ms (cursor ${this.pushedThrough})`
|
|
1224
|
+
);
|
|
1144
1225
|
}
|
|
1145
1226
|
}
|
|
1146
1227
|
/** Queue a change for throttled broadcast (deduped by hash). */
|
|
@@ -1477,18 +1558,24 @@ function createRegistryStorageAdapter(storage) {
|
|
|
1477
1558
|
return {
|
|
1478
1559
|
async get(key) {
|
|
1479
1560
|
try {
|
|
1561
|
+
if (storage.getAppState) {
|
|
1562
|
+
const json = await storage.getAppState(key);
|
|
1563
|
+
return json ? JSON.parse(json) : null;
|
|
1564
|
+
}
|
|
1480
1565
|
const content = await storage.getDocumentContent(key);
|
|
1481
1566
|
if (!content || content.length === 0) return null;
|
|
1482
|
-
|
|
1483
|
-
return JSON.parse(json);
|
|
1567
|
+
return JSON.parse(decoder.decode(content));
|
|
1484
1568
|
} catch {
|
|
1485
1569
|
return null;
|
|
1486
1570
|
}
|
|
1487
1571
|
},
|
|
1488
1572
|
async set(key, entries) {
|
|
1489
1573
|
const json = JSON.stringify(entries);
|
|
1490
|
-
|
|
1491
|
-
|
|
1574
|
+
if (storage.setAppState) {
|
|
1575
|
+
await storage.setAppState(key, json);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
await storage.setDocumentContent(key, encoder.encode(json));
|
|
1492
1579
|
}
|
|
1493
1580
|
};
|
|
1494
1581
|
}
|
|
@@ -1669,6 +1756,7 @@ function createSyncManager(config) {
|
|
|
1669
1756
|
const offlineQueue = createOfflineQueue({
|
|
1670
1757
|
storage: config.storage
|
|
1671
1758
|
});
|
|
1759
|
+
let offlineQueueReady = Promise.resolve();
|
|
1672
1760
|
const nodeSyncProvider = config.nodeSyncRoom ? new NodeStoreSyncProvider(config.nodeStore, config.nodeSyncRoom) : null;
|
|
1673
1761
|
let blobSync = null;
|
|
1674
1762
|
if (config.blobStore) {
|
|
@@ -2128,9 +2216,16 @@ function createSyncManager(config) {
|
|
|
2128
2216
|
if (lifecycleInput.stopped) return;
|
|
2129
2217
|
log2("Registry loaded, tracked nodes:", registry.getTracked().length);
|
|
2130
2218
|
attachPersistenceListeners();
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2219
|
+
offlineQueueReady = offlineQueue.load().then(() => {
|
|
2220
|
+
log2("Offline queue loaded, size:", offlineQueue.size);
|
|
2221
|
+
updateLifecycle({ localReady: true });
|
|
2222
|
+
if (connection.status === "connected") {
|
|
2223
|
+
void drainOfflineQueue();
|
|
2224
|
+
}
|
|
2225
|
+
}).catch((err) => {
|
|
2226
|
+
log2("Offline queue load failed:", err);
|
|
2227
|
+
updateLifecycle({ localReady: true });
|
|
2228
|
+
});
|
|
2134
2229
|
log2("Connecting to signaling server...");
|
|
2135
2230
|
connection.connect();
|
|
2136
2231
|
nodeSyncProvider?.attach(connection);
|
|
@@ -2163,6 +2258,7 @@ function createSyncManager(config) {
|
|
|
2163
2258
|
await pool.flushAll();
|
|
2164
2259
|
registry.prune();
|
|
2165
2260
|
await registry.save();
|
|
2261
|
+
await offlineQueueReady.catch(() => void 0);
|
|
2166
2262
|
await offlineQueue.save();
|
|
2167
2263
|
await pool.destroy();
|
|
2168
2264
|
},
|
|
@@ -2541,6 +2637,158 @@ function liveQuery(client, schema, options) {
|
|
|
2541
2637
|
};
|
|
2542
2638
|
}
|
|
2543
2639
|
|
|
2640
|
+
// src/adapter-conformance.ts
|
|
2641
|
+
import { defineSchema, text } from "@xnetjs/data";
|
|
2642
|
+
var ConformanceSchema = defineSchema({
|
|
2643
|
+
name: "ConformanceItem",
|
|
2644
|
+
namespace: "xnet://adapter-conformance/",
|
|
2645
|
+
properties: { label: text({ required: true }) }
|
|
2646
|
+
});
|
|
2647
|
+
var AdapterConformanceError = class extends Error {
|
|
2648
|
+
checks;
|
|
2649
|
+
constructor(checks) {
|
|
2650
|
+
const failed = checks.filter((c) => !c.passed).map((c) => `${c.name}: ${c.detail ?? "failed"}`);
|
|
2651
|
+
super(`adapter conformance failed:
|
|
2652
|
+
${failed.join("\n ")}`);
|
|
2653
|
+
this.name = "AdapterConformanceError";
|
|
2654
|
+
this.checks = checks;
|
|
2655
|
+
}
|
|
2656
|
+
};
|
|
2657
|
+
var tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
2658
|
+
function denyWrites() {
|
|
2659
|
+
const decide = async (input) => ({
|
|
2660
|
+
allowed: input.action === "read",
|
|
2661
|
+
action: input.action,
|
|
2662
|
+
subject: input.subject,
|
|
2663
|
+
resource: input.nodeId,
|
|
2664
|
+
roles: [],
|
|
2665
|
+
grants: [],
|
|
2666
|
+
reasons: [],
|
|
2667
|
+
cached: false,
|
|
2668
|
+
evaluatedAt: 0,
|
|
2669
|
+
duration: 0
|
|
2670
|
+
});
|
|
2671
|
+
return {
|
|
2672
|
+
can: decide,
|
|
2673
|
+
explain: async () => {
|
|
2674
|
+
throw new Error("conformance: explain() is not exercised");
|
|
2675
|
+
},
|
|
2676
|
+
invalidate: () => {
|
|
2677
|
+
},
|
|
2678
|
+
invalidateSubject: () => {
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
async function withClient(makeClient, overrides, body) {
|
|
2683
|
+
const client = await makeClient(overrides);
|
|
2684
|
+
try {
|
|
2685
|
+
await body(client);
|
|
2686
|
+
} finally {
|
|
2687
|
+
await client.destroy();
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
function assert(condition, message) {
|
|
2691
|
+
if (!condition) throw new Error(message);
|
|
2692
|
+
}
|
|
2693
|
+
var CHECKS = [
|
|
2694
|
+
{
|
|
2695
|
+
name: "live-query:immediate-and-update",
|
|
2696
|
+
run: (makeClient) => withClient(makeClient, void 0, async (client) => {
|
|
2697
|
+
const items = liveQuery(client, ConformanceSchema);
|
|
2698
|
+
const seen = [];
|
|
2699
|
+
const unsubscribe = items.subscribe((rows) => seen.push(rows === null ? null : rows.length));
|
|
2700
|
+
assert(seen.length >= 1, "subscribe did not deliver an immediate snapshot");
|
|
2701
|
+
await client.mutate.create(ConformanceSchema, { label: "alpha" });
|
|
2702
|
+
await tick();
|
|
2703
|
+
assert(seen.at(-1) === 1, `expected 1 row after create, saw ${String(seen.at(-1))}`);
|
|
2704
|
+
assert(items.get()?.length === 1, "get() did not reflect the write");
|
|
2705
|
+
unsubscribe();
|
|
2706
|
+
items.destroy();
|
|
2707
|
+
})
|
|
2708
|
+
},
|
|
2709
|
+
{
|
|
2710
|
+
name: "live-query:stops-after-unsubscribe",
|
|
2711
|
+
run: (makeClient) => withClient(makeClient, void 0, async (client) => {
|
|
2712
|
+
const items = liveQuery(client, ConformanceSchema);
|
|
2713
|
+
let calls = 0;
|
|
2714
|
+
const unsubscribe = items.subscribe(() => {
|
|
2715
|
+
calls += 1;
|
|
2716
|
+
});
|
|
2717
|
+
const afterSubscribe = calls;
|
|
2718
|
+
unsubscribe();
|
|
2719
|
+
await client.mutate.create(ConformanceSchema, { label: "ignored" });
|
|
2720
|
+
await tick();
|
|
2721
|
+
assert(calls === afterSubscribe, "subscriber was notified after unsubscribe");
|
|
2722
|
+
items.destroy();
|
|
2723
|
+
})
|
|
2724
|
+
},
|
|
2725
|
+
{
|
|
2726
|
+
name: "mutate:round-trips-via-fetch",
|
|
2727
|
+
run: (makeClient) => withClient(makeClient, void 0, async (client) => {
|
|
2728
|
+
await client.mutate.create(ConformanceSchema, { label: "beta" });
|
|
2729
|
+
const rows = await client.fetch(ConformanceSchema);
|
|
2730
|
+
assert(rows.length === 1, `expected 1 fetched row, saw ${rows.length}`);
|
|
2731
|
+
assert(rows[0].properties.label === "beta", "fetched row lost its properties");
|
|
2732
|
+
})
|
|
2733
|
+
},
|
|
2734
|
+
{
|
|
2735
|
+
name: "auth:permissive-by-default",
|
|
2736
|
+
run: (makeClient) => withClient(makeClient, void 0, async (client) => {
|
|
2737
|
+
const decision = await client.can({
|
|
2738
|
+
subject: client.authorDID,
|
|
2739
|
+
action: "write",
|
|
2740
|
+
nodeId: "conformance-node"
|
|
2741
|
+
});
|
|
2742
|
+
assert(typeof decision.allowed === "boolean", "can() did not return an AuthDecision");
|
|
2743
|
+
assert(decision.allowed === true, "default client was not permissive");
|
|
2744
|
+
})
|
|
2745
|
+
},
|
|
2746
|
+
{
|
|
2747
|
+
name: "auth:denial-surfaces",
|
|
2748
|
+
run: (makeClient) => withClient(makeClient, { authEvaluator: denyWrites() }, async (client) => {
|
|
2749
|
+
const write = await client.can({
|
|
2750
|
+
subject: client.authorDID,
|
|
2751
|
+
action: "write",
|
|
2752
|
+
nodeId: "conformance-node"
|
|
2753
|
+
});
|
|
2754
|
+
const read = await client.can({
|
|
2755
|
+
subject: client.authorDID,
|
|
2756
|
+
action: "read",
|
|
2757
|
+
nodeId: "conformance-node"
|
|
2758
|
+
});
|
|
2759
|
+
assert(write.allowed === false, "denied write was reported as allowed");
|
|
2760
|
+
assert(read.allowed === true, "allowed read was reported as denied");
|
|
2761
|
+
})
|
|
2762
|
+
},
|
|
2763
|
+
{
|
|
2764
|
+
name: "lifecycle:destroy-is-idempotent",
|
|
2765
|
+
run: async (makeClient) => {
|
|
2766
|
+
const client = await makeClient();
|
|
2767
|
+
await client.destroy();
|
|
2768
|
+
await client.destroy();
|
|
2769
|
+
assert(client.runtimeStatus.phase === "destroyed", "phase was not destroyed after destroy()");
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
];
|
|
2773
|
+
async function runAdapterConformance(makeClient) {
|
|
2774
|
+
const checks = [];
|
|
2775
|
+
for (const check of CHECKS) {
|
|
2776
|
+
try {
|
|
2777
|
+
await check.run(makeClient);
|
|
2778
|
+
checks.push({ name: check.name, passed: true });
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
checks.push({
|
|
2781
|
+
name: check.name,
|
|
2782
|
+
passed: false,
|
|
2783
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
const passed = checks.every((c) => c.passed);
|
|
2788
|
+
if (!passed) throw new AdapterConformanceError(checks);
|
|
2789
|
+
return { passed, checks };
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2544
2792
|
// src/sync/WebSocketSyncProvider.ts
|
|
2545
2793
|
import { resolveSyncReplicationPolicy as resolveSyncReplicationPolicy2, signYjsUpdate as signYjsUpdate2, verifyYjsEnvelopeV1 as verifyYjsEnvelopeV12 } from "@xnetjs/sync";
|
|
2546
2794
|
import {
|
|
@@ -2919,6 +3167,163 @@ var WebSocketSyncProvider = class {
|
|
|
2919
3167
|
};
|
|
2920
3168
|
};
|
|
2921
3169
|
|
|
3170
|
+
// src/sync/MultiHubSyncManager.ts
|
|
3171
|
+
import {
|
|
3172
|
+
planReplicationDestinations
|
|
3173
|
+
} from "@xnetjs/sync";
|
|
3174
|
+
var defaultRoomForNode = (nodeId) => `xnet-doc-${nodeId}`;
|
|
3175
|
+
function createMultiHubSyncManager(config) {
|
|
3176
|
+
const hubs = /* @__PURE__ */ new Map();
|
|
3177
|
+
for (const hub of config.hubs) {
|
|
3178
|
+
if (hub.hubId && !hubs.has(hub.hubId)) hubs.set(hub.hubId, hub);
|
|
3179
|
+
}
|
|
3180
|
+
const roomForNode = config.roomForNode ?? defaultRoomForNode;
|
|
3181
|
+
const activeRooms = /* @__PURE__ */ new Set();
|
|
3182
|
+
let replication = config.replication;
|
|
3183
|
+
function effectiveConfig() {
|
|
3184
|
+
const overlays = new Map(
|
|
3185
|
+
(replication?.federation?.hubs ?? []).map((hub) => [hub.id, hub])
|
|
3186
|
+
);
|
|
3187
|
+
const mergedHubs = [...hubs.values()].map((hub) => {
|
|
3188
|
+
const overlay = overlays.get(hub.hubId);
|
|
3189
|
+
return { id: hub.hubId, url: hub.url, ...overlay ?? {} };
|
|
3190
|
+
});
|
|
3191
|
+
for (const [id, overlay] of overlays) {
|
|
3192
|
+
if (!hubs.has(id)) mergedHubs.push(overlay);
|
|
3193
|
+
}
|
|
3194
|
+
return {
|
|
3195
|
+
...replication?.compatibility ? { compatibility: replication.compatibility } : {},
|
|
3196
|
+
federation: {
|
|
3197
|
+
hubs: mergedHubs,
|
|
3198
|
+
...replication?.federation?.namespacePolicies ? { namespacePolicies: replication.federation.namespacePolicies } : {},
|
|
3199
|
+
...replication?.federation?.defaultSystemHubIds ? { defaultSystemHubIds: replication.federation.defaultSystemHubIds } : {},
|
|
3200
|
+
...replication?.federation?.defaultUserHubIds ? { defaultUserHubIds: replication.federation.defaultUserHubIds } : {}
|
|
3201
|
+
}
|
|
3202
|
+
};
|
|
3203
|
+
}
|
|
3204
|
+
function planFor(namespace) {
|
|
3205
|
+
return planReplicationDestinations({ namespace, config: effectiveConfig() });
|
|
3206
|
+
}
|
|
3207
|
+
function reachableHubIds(namespace) {
|
|
3208
|
+
return planFor(namespace).destinations.map((destination) => destination.hubId).filter((hubId) => hubs.has(hubId));
|
|
3209
|
+
}
|
|
3210
|
+
function reconcileRoom(room) {
|
|
3211
|
+
const wanted = new Set(reachableHubIds(room.namespace));
|
|
3212
|
+
for (const [hubId, unsubscribe] of room.subscriptions) {
|
|
3213
|
+
if (!wanted.has(hubId)) {
|
|
3214
|
+
unsubscribe();
|
|
3215
|
+
room.subscriptions.delete(hubId);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
for (const hubId of wanted) {
|
|
3219
|
+
if (!room.subscriptions.has(hubId)) {
|
|
3220
|
+
const hub = hubs.get(hubId);
|
|
3221
|
+
if (hub) room.subscriptions.set(hubId, hub.transport.joinRoom(room.room, room.handler));
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return {
|
|
3226
|
+
planFor,
|
|
3227
|
+
plannedHubs(namespace) {
|
|
3228
|
+
return planFor(namespace).destinations.flatMap((destination) => {
|
|
3229
|
+
const hub = hubs.get(destination.hubId);
|
|
3230
|
+
return hub ? [{ hubId: hub.hubId, url: hub.url, trust: hub.trust }] : [];
|
|
3231
|
+
});
|
|
3232
|
+
},
|
|
3233
|
+
destinationsFor: reachableHubIds,
|
|
3234
|
+
joinScopedRoom(nodeId, namespace, handler) {
|
|
3235
|
+
const room = roomForNode(nodeId);
|
|
3236
|
+
const active = {
|
|
3237
|
+
nodeId,
|
|
3238
|
+
namespace,
|
|
3239
|
+
room,
|
|
3240
|
+
handler,
|
|
3241
|
+
subscriptions: /* @__PURE__ */ new Map()
|
|
3242
|
+
};
|
|
3243
|
+
for (const hubId of reachableHubIds(namespace)) {
|
|
3244
|
+
const hub = hubs.get(hubId);
|
|
3245
|
+
if (hub) active.subscriptions.set(hubId, hub.transport.joinRoom(room, handler));
|
|
3246
|
+
}
|
|
3247
|
+
activeRooms.add(active);
|
|
3248
|
+
return {
|
|
3249
|
+
plan: planFor(namespace),
|
|
3250
|
+
get hubIds() {
|
|
3251
|
+
return [...active.subscriptions.keys()];
|
|
3252
|
+
},
|
|
3253
|
+
leave() {
|
|
3254
|
+
for (const unsubscribe of active.subscriptions.values()) unsubscribe();
|
|
3255
|
+
active.subscriptions.clear();
|
|
3256
|
+
activeRooms.delete(active);
|
|
3257
|
+
}
|
|
3258
|
+
};
|
|
3259
|
+
},
|
|
3260
|
+
publishScoped(namespace, room, data) {
|
|
3261
|
+
for (const hubId of reachableHubIds(namespace)) {
|
|
3262
|
+
hubs.get(hubId)?.transport.publish(room, data);
|
|
3263
|
+
}
|
|
3264
|
+
},
|
|
3265
|
+
setReplication(next) {
|
|
3266
|
+
replication = next;
|
|
3267
|
+
for (const room of activeRooms) reconcileRoom(room);
|
|
3268
|
+
},
|
|
3269
|
+
connect() {
|
|
3270
|
+
for (const hub of hubs.values()) hub.transport.connect();
|
|
3271
|
+
},
|
|
3272
|
+
disconnect() {
|
|
3273
|
+
for (const room of activeRooms) {
|
|
3274
|
+
for (const unsubscribe of room.subscriptions.values()) unsubscribe();
|
|
3275
|
+
room.subscriptions.clear();
|
|
3276
|
+
}
|
|
3277
|
+
activeRooms.clear();
|
|
3278
|
+
for (const hub of hubs.values()) hub.transport.disconnect();
|
|
3279
|
+
}
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
// src/sync/replication-scope.ts
|
|
3284
|
+
function spaceNamespace(ownerDID, spaceId) {
|
|
3285
|
+
return `xnet://${ownerDID}/space/${spaceId}/`;
|
|
3286
|
+
}
|
|
3287
|
+
function systemNamespace(ownerDID) {
|
|
3288
|
+
return `xnet://${ownerDID}/sys/`;
|
|
3289
|
+
}
|
|
3290
|
+
function namespaceForNode(node, fallbackOwnerDID) {
|
|
3291
|
+
const owner = node.createdBy ?? fallbackOwnerDID ?? "unknown";
|
|
3292
|
+
return node.space ? spaceNamespace(owner, node.space) : spaceNamespace(owner, node.id);
|
|
3293
|
+
}
|
|
3294
|
+
function replicationConfigFromPolicies(policies) {
|
|
3295
|
+
const hubsById = /* @__PURE__ */ new Map();
|
|
3296
|
+
const namespacePolicies = [];
|
|
3297
|
+
for (const policy of policies) {
|
|
3298
|
+
const includeHubIds = [];
|
|
3299
|
+
let minReplicas = 0;
|
|
3300
|
+
for (const destination of policy.destinations) {
|
|
3301
|
+
if (!hubsById.has(destination.hubId)) {
|
|
3302
|
+
hubsById.set(destination.hubId, {
|
|
3303
|
+
id: destination.hubId,
|
|
3304
|
+
url: destination.url,
|
|
3305
|
+
...destination.priority === void 0 ? {} : { priority: destination.priority }
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
includeHubIds.push(destination.hubId);
|
|
3309
|
+
if (destination.minReplicas && destination.minReplicas > minReplicas) {
|
|
3310
|
+
minReplicas = destination.minReplicas;
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
namespacePolicies.push({
|
|
3314
|
+
namespace: spaceNamespace(policy.ownerDID, policy.space),
|
|
3315
|
+
includeHubIds,
|
|
3316
|
+
...minReplicas > 0 ? { minHubs: minReplicas } : {}
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3319
|
+
return {
|
|
3320
|
+
federation: {
|
|
3321
|
+
hubs: [...hubsById.values()],
|
|
3322
|
+
namespacePolicies
|
|
3323
|
+
}
|
|
3324
|
+
};
|
|
3325
|
+
}
|
|
3326
|
+
|
|
2922
3327
|
// src/sync/InitialSyncManager.ts
|
|
2923
3328
|
function createInitialSyncManager() {
|
|
2924
3329
|
let progress = {
|
|
@@ -3035,6 +3440,7 @@ function isProtocolCompatible(theirs) {
|
|
|
3035
3440
|
return negotiateProtocolVersion(XNET_SUPPORTED_PROTOCOL_VERSIONS, theirs) !== null;
|
|
3036
3441
|
}
|
|
3037
3442
|
export {
|
|
3443
|
+
AdapterConformanceError,
|
|
3038
3444
|
METABRIDGE_ORIGIN,
|
|
3039
3445
|
METABRIDGE_SEED_ORIGIN,
|
|
3040
3446
|
NodeStoreSyncProvider,
|
|
@@ -3050,6 +3456,7 @@ export {
|
|
|
3050
3456
|
createInitialSyncManager,
|
|
3051
3457
|
createMetaBridge,
|
|
3052
3458
|
createMultiHubConnectionManager,
|
|
3459
|
+
createMultiHubSyncManager,
|
|
3053
3460
|
createNodePool,
|
|
3054
3461
|
createOfflineQueue,
|
|
3055
3462
|
createRegistry,
|
|
@@ -3057,5 +3464,10 @@ export {
|
|
|
3057
3464
|
createXNetClient,
|
|
3058
3465
|
isProtocolCompatible,
|
|
3059
3466
|
liveQuery,
|
|
3060
|
-
|
|
3467
|
+
namespaceForNode,
|
|
3468
|
+
negotiateProtocolVersion,
|
|
3469
|
+
replicationConfigFromPolicies,
|
|
3470
|
+
runAdapterConformance,
|
|
3471
|
+
spaceNamespace,
|
|
3472
|
+
systemNamespace
|
|
3061
3473
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xnetjs/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Framework-agnostic xNet runtime: createXNetClient() and sync orchestration, usable from any framework, a CLI, a worker, or a Node service",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,15 +28,15 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"y-protocols": "^1.0.6",
|
|
30
30
|
"yjs": "^13.6.24",
|
|
31
|
-
"@xnetjs/
|
|
32
|
-
"@xnetjs/
|
|
33
|
-
"@xnetjs/
|
|
34
|
-
"@xnetjs/
|
|
35
|
-
"@xnetjs/
|
|
36
|
-
"@xnetjs/
|
|
37
|
-
"@xnetjs/
|
|
38
|
-
"@xnetjs/
|
|
39
|
-
"@xnetjs/sync": "0.
|
|
31
|
+
"@xnetjs/crypto": "0.1.1",
|
|
32
|
+
"@xnetjs/data-bridge": "0.1.1",
|
|
33
|
+
"@xnetjs/history": "0.1.1",
|
|
34
|
+
"@xnetjs/identity": "0.1.1",
|
|
35
|
+
"@xnetjs/plugins": "0.1.1",
|
|
36
|
+
"@xnetjs/storage": "0.1.1",
|
|
37
|
+
"@xnetjs/core": "0.1.1",
|
|
38
|
+
"@xnetjs/data": "0.1.1",
|
|
39
|
+
"@xnetjs/sync": "0.1.1"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"jsdom": "^26.0.0",
|