@xnetjs/runtime 0.0.2 → 0.1.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/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
- See `docs/explorations/0185_*` for the design rationale.
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
- const content = Y.encodeStateAsUpdate(entry.doc);
745
- await config.storage.setDocumentContent(id, content);
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
- changes.sort((a, b) => a.lamport - b.lamport || a.authorDID.localeCompare(b.authorDID));
1142
- for (const change of changes) {
1143
- this.enqueueChange(change);
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
- const json = decoder.decode(content);
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
- const bytes = encoder.encode(json);
1491
- await storage.setDocumentContent(key, bytes);
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
- await offlineQueue.load();
2132
- log2("Offline queue loaded, size:", offlineQueue.size);
2133
- updateLifecycle({ localReady: true });
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
- negotiateProtocolVersion
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.0.2",
3
+ "version": "0.1.0",
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/core": "0.0.3",
32
- "@xnetjs/crypto": "0.0.3",
33
- "@xnetjs/data": "0.0.3",
34
- "@xnetjs/data-bridge": "0.0.3",
35
- "@xnetjs/history": "0.0.3",
36
- "@xnetjs/identity": "0.0.3",
37
- "@xnetjs/plugins": "0.0.3",
38
- "@xnetjs/storage": "0.0.3",
39
- "@xnetjs/sync": "0.0.3"
31
+ "@xnetjs/core": "0.1.0",
32
+ "@xnetjs/crypto": "0.1.0",
33
+ "@xnetjs/data-bridge": "0.1.0",
34
+ "@xnetjs/data": "0.1.0",
35
+ "@xnetjs/history": "0.1.0",
36
+ "@xnetjs/identity": "0.1.0",
37
+ "@xnetjs/plugins": "0.1.0",
38
+ "@xnetjs/storage": "0.1.0",
39
+ "@xnetjs/sync": "0.1.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "jsdom": "^26.0.0",