@lyku/para-sync 0.0.1-pre.1 → 0.0.1-pre.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyku/para-sync",
3
- "version": "0.0.1-pre.1",
3
+ "version": "0.0.1-pre.2",
4
4
  "description": "synced<T> distributed object sync for the para:* suite — server-authoritative records with live, version-reconciled client replicas. Pre-release: API may change before 0.1.0. Currently provides the pluggable SyncTransport interface and the InProcessTransport implementation (monolith/edge/no-bus deployments); NatsTransport and the synced<T> primitive land on top.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/client.d.ts CHANGED
@@ -1,36 +1,3 @@
1
- /** @typedef {import('./transport.js').SyncEnvelope} SyncEnvelope */
2
- /** @typedef {import('./transport.js').SyncTransport} SyncTransport */
3
- /**
4
- * A schema's parse result — matches para-schema's Result<T, string>.
5
- * @typedef {{ tag: 'Ok', value: any } | { tag: 'Err', error: string }} Result
6
- */
7
- /**
8
- * Anything with a `parse` returning {@link Result}. In production this is a
9
- * para-schema `SchemaValue`; in tests it can be a hand-rolled gate. The replica
10
- * depends only on this shape, never on para-schema directly — which is also why
11
- * the client gates branch on `.tag` instead of using the throw-on-Err `::`
12
- * convention (a malformed delta must trigger recovery, not crash the apply).
13
- * @typedef {{ parse(v: unknown): Result }} SyncSchema
14
- */
15
- /**
16
- * A minimal reactive value cell: get / peek / set. A para-signals `signal()`
17
- * satisfies it exactly and is the default. Injectable for testing and for the
18
- * fork-backed cell on the para-svelte side.
19
- * @typedef {{ get(): any, peek(): any, set(v: any): void }} Cell
20
- */
21
- /**
22
- * @typedef {'ok' | 'stale' | 'skew' | 'refetching'} ReplicaStatus
23
- * - ok last apply succeeded; replica is current
24
- * - stale uninitialized, or a refetch failed / none available
25
- * - skew an inbound value failed `parse` (malformed or schema-skew)
26
- * - refetching a recovery refetch is in flight
27
- */
28
- /**
29
- * @typedef {object} ReplicaMeta
30
- * @property {string | null} schemaVersion schema version of the applied value
31
- * @property {number} sequence sequence of the applied value (-1 if uninitialized)
32
- * @property {ReplicaStatus} status
33
- */
34
1
  /**
35
2
  * Create a client replica for one synced key.
36
3
  *
@@ -42,14 +9,19 @@
42
9
  * @param {() => Promise<SyncEnvelope>} [opts.refetch] Err/skew/gap fallback: fetch the
43
10
  * current authoritative snapshot. Omit → no recovery (status goes 'stale').
44
11
  * @param {Cell} [opts.cell] reactive cell (default: a para signal)
12
+ * @param {string} [opts.schemaVersion] the client's expected schema
13
+ * version ("major.minor"). When set, an inbound envelope whose MAJOR
14
+ * differs is treated as a breaking skew: not applied, recovery refetched.
15
+ * A minor difference is compatible and falls through to the parse gate.
45
16
  */
46
- export function createClientReplica({ key, schema, transport, seed, refetch, cell }: {
17
+ export function createClientReplica({ key, schema, transport, seed, refetch, cell, schemaVersion, }: {
47
18
  key: string;
48
19
  schema: SyncSchema;
49
20
  transport: SyncTransport;
50
21
  seed?: SyncEnvelope;
51
22
  refetch?: () => Promise<SyncEnvelope>;
52
23
  cell?: Cell;
24
+ schemaVersion?: string;
53
25
  }): {
54
26
  /** current value (tracked read) */
55
27
  get: () => any;
@@ -66,6 +38,7 @@ export function createClientReplica({ key, schema, transport, seed, refetch, cel
66
38
  gaps: number;
67
39
  parseErrors: number;
68
40
  refetches: number;
41
+ schemaSkews: number;
69
42
  };
70
43
  /** resolves when no recovery refetch is in flight (test/await aid) */
71
44
  whenIdle: () => Promise<void>;
package/src/client.js CHANGED
@@ -50,6 +50,21 @@ import { signal } from "@lyku/para-signals";
50
50
  * @property {ReplicaStatus} status
51
51
  */
52
52
 
53
+ /**
54
+ * Compare two "major.minor" version strings by MAJOR only. Returns true iff both
55
+ * are well-formed and their majors differ (a breaking change). Missing/malformed
56
+ * versions return false — let the parse gate be the backstop rather than block on
57
+ * a version-format quirk.
58
+ * @param {string | undefined} a
59
+ * @param {string | undefined} b
60
+ */
61
+ function majorMismatch(a, b) {
62
+ const ma = /^(\d+)\./.exec(a == null ? "" : String(a));
63
+ const mb = /^(\d+)\./.exec(b == null ? "" : String(b));
64
+ if (ma === null || mb === null) return false;
65
+ return ma[1] !== mb[1];
66
+ }
67
+
53
68
  /**
54
69
  * Create a client replica for one synced key.
55
70
  *
@@ -61,8 +76,20 @@ import { signal } from "@lyku/para-signals";
61
76
  * @param {() => Promise<SyncEnvelope>} [opts.refetch] Err/skew/gap fallback: fetch the
62
77
  * current authoritative snapshot. Omit → no recovery (status goes 'stale').
63
78
  * @param {Cell} [opts.cell] reactive cell (default: a para signal)
79
+ * @param {string} [opts.schemaVersion] the client's expected schema
80
+ * version ("major.minor"). When set, an inbound envelope whose MAJOR
81
+ * differs is treated as a breaking skew: not applied, recovery refetched.
82
+ * A minor difference is compatible and falls through to the parse gate.
64
83
  */
65
- export function createClientReplica({ key, schema, transport, seed, refetch, cell }) {
84
+ export function createClientReplica({
85
+ key,
86
+ schema,
87
+ transport,
88
+ seed,
89
+ refetch,
90
+ cell,
91
+ schemaVersion,
92
+ }) {
66
93
  const value = cell ?? signal(undefined);
67
94
  /** @type {Cell} */
68
95
  const meta = signal(
@@ -73,7 +100,14 @@ export function createClientReplica({ key, schema, transport, seed, refetch, cel
73
100
  let disposed = false;
74
101
  let pending = Promise.resolve();
75
102
 
76
- const stats = { applied: 0, ignoredStale: 0, gaps: 0, parseErrors: 0, refetches: 0 };
103
+ const stats = {
104
+ applied: 0,
105
+ ignoredStale: 0,
106
+ gaps: 0,
107
+ parseErrors: 0,
108
+ refetches: 0,
109
+ schemaSkews: 0,
110
+ };
77
111
 
78
112
  /** @param {Partial<ReplicaMeta>} patch */
79
113
  const setMeta = (patch) => meta.set({ ...meta.peek(), ...patch });
@@ -110,6 +144,22 @@ export function createClientReplica({ key, schema, transport, seed, refetch, cel
110
144
  function ingest(envelope, source) {
111
145
  if (disposed) return;
112
146
 
147
+ // ── schema-version gate ──
148
+ // A breaking (major) schema-version difference means the value was produced
149
+ // against an incompatible shape. Don't apply it — the parse gate would
150
+ // likely reject it too, but the version is the explicit, earlier signal and
151
+ // tells us this is "different shape" (refetch), not "behind but compatible".
152
+ // A minor difference (same major) is compatible and falls through to parse.
153
+ if (
154
+ schemaVersion !== undefined &&
155
+ majorMismatch(schemaVersion, envelope.schema_version)
156
+ ) {
157
+ stats.schemaSkews++;
158
+ setMeta({ status: "skew" });
159
+ if (source !== "refetch") startRefetch();
160
+ return;
161
+ }
162
+
113
163
  // ── parse gate (every inbound value crosses a trust boundary) ──
114
164
  const res = schema.parse(envelope.value);
115
165
  if (res.tag !== "Ok") {