{"_id":"@braidlabs/skew","name":"@braidlabs/skew","dist-tags":{"latest":"0.1.1"},"versions":{"0.1.1":{"name":"@braidlabs/skew","version":"0.1.1","description":"Framework-agnostic primitives for surviving version skew: versioned envelopes, migration chains, build identity and negotiation.","keywords":["version-skew","migration","schema-versioning","deployment","offline"],"license":"MIT","sideEffects":false,"type":"module","main":"./src/index.js","types":"./src/index.d.ts","exports":{".":{"types":"./src/index.d.ts","default":"./src/index.js"},"./package.json":"./package.json"},"publishConfig":{"access":"public"},"repository":{"type":"git","url":"git+https://github.com/braidjs/braid.git","directory":"libs/skew"},"homepage":"https://github.com/braidjs/braid#readme","bugs":{"url":"https://github.com/braidjs/braid/issues"},"module":"./src/index.js","gitHead":"ed41199db752caec2c208b72be9e4cd9d3d19c90","_id":"@braidlabs/skew@0.1.1","_nodeVersion":"24.19.0","_npmVersion":"11.17.0","dist":{"integrity":"sha512-Qe8cCnZcYH00dcyAj3HT3y8HfS/mxiyLXG+6UJxQKYGD9/lVOqoeZ5XCkKqEM5vH/Dcgp93eOvvKihmgLwNdFQ==","shasum":"a4451f216250fc9803dd57adb3b27d0c81cba704","tarball":"https://registry.npmjs.org/@braidlabs/skew/-/skew-0.1.1.tgz","fileCount":41,"unpackedSize":158762,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@braidlabs%2fskew@0.1.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIHp6UUt338Jisq8OpBrs8HZQkvMyXJfvVH2fiHETKvGEAiEAmER2qyPic2B1iQRVFSxVGcKY1PU3K5Ocigb4oBFek1M="}]},"_npmUser":{"name":"jsmith6690","email":"jsmith6690@gmail.com"},"directories":{},"maintainers":[{"name":"jsmith6690","email":"jsmith6690@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/skew_0.1.1_1787495680391_0.4820647777853"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-23T14:34:40.264Z","0.1.1":"2026-08-23T14:34:40.556Z","modified":"2026-08-23T14:34:40.947Z"},"maintainers":[{"name":"jsmith6690","email":"jsmith6690@gmail.com"}],"description":"Framework-agnostic primitives for surviving version skew: versioned envelopes, migration chains, build identity and negotiation.","homepage":"https://github.com/braidjs/braid#readme","keywords":["version-skew","migration","schema-versioning","deployment","offline"],"repository":{"type":"git","url":"git+https://github.com/braidjs/braid.git","directory":"libs/skew"},"bugs":{"url":"https://github.com/braidjs/braid/issues"},"license":"MIT","readme":"# @braidlabs/skew\n\nFramework-agnostic primitives for surviving version skew.\n\nNo dependencies. No framework. Works in browsers, Node, workers, and Deno.\n\n---\n\n## The problem\n\nFour failures that look unrelated are the same failure:\n\n| Boundary | What crosses it | Symptom |\n|---|---|---|\n| Client ↔ origin | a lazy chunk request | `ChunkLoadError` after a deploy |\n| Client ↔ API | a queued mutation, flushed later | 400s, or silent data corruption |\n| Host ↔ fragment | props and events | mismatched contracts at runtime |\n| **Past self ↔ present self** | a persisted draft or cache | `undefined` deep in a renderer |\n\nIn every case, two independently-versioned parties met at a boundary and had no way to discover that they disagreed.\n\nThis package is that missing primitive: **stamp what crosses a boundary with the version it was authored under, detect disagreement, then migrate forward or fail loudly.**\n\nThe fourth row is the one most teams miss. A draft written by build 41 and resumed by build 57 is the same problem as a client on 41 calling a server on 57 — the counterparty is just your own past deployment.\n\n---\n\n## Install\n\n```sh\nnpm install @braidlabs/skew\n```\n\n---\n\n## Versioned schemas\n\nDeclare a type's current version, its history, and the functions that move data between them in one place.\n\n```ts\nimport { versioned } from '@braidlabs/skew';\n\n// Snapshot shapes — frozen copies, never your live application types.\ninterface V1 { id: string; themeQuote?: { text: string } }\ninterface V2 { id: string; scriptureOfWeek?: { text: string } }\ntype V3 = V2 & { orderOfWorship: { setting: string; hymns: string[] } };\n\nexport const WeeklyContent = versioned<V1>('weekly-content')\n  .next<V2>('rename themeQuote to scriptureOfWeek', (p) => ({\n    id: p.id,\n    scriptureOfWeek: p.themeQuote,\n  }))\n  .next<V3>('introduce orderOfWorship', (p) => ({\n    ...p,\n    orderOfWorship: { setting: '', hymns: [] },\n  }));\n```\n\nReading migrates forward automatically:\n\n```ts\nconst result = WeeklyContent.read(rawFromFirestore);\n\nif (result.ok) {\n  render(result.value);              // always the current shape\n  if (result.migratedFrom !== null) {\n    console.info(`upgraded from v${result.migratedFrom}`);\n  }\n}\n```\n\nEach `next()` is typed against the previous version, so a migration that does not actually produce the next shape is a compile error. There is no terminal `build()` call — the chain *is* the schema.\n\n### The one rule\n\n**A migration must never import your current application types or services.** Close each step over its own snapshot type (`V1`, `V2`, …). The moment a migration references a live interface, it silently changes meaning the next time that interface is edited, and your old migrations start lying about what they produce.\n\n---\n\n## Results, not exceptions\n\n`read()` returns a discriminated result, because the failure modes need *different* remedies:\n\n```ts\nconst result = WeeklyContent.read(raw);\n\nif (!result.ok) {\n  switch (result.reason) {\n    case 'ahead':   return refetchFromServer();  // written by a NEWER build\n    case 'gap':     return reportBug(result);    // missing migration step\n    case 'invalid': return discardAndRefetch();\n    case 'threw':   return reportBug(result);    // a migration failed\n    case 'retired': return discardAndRefetch();  // below the declared floor — policy, not a bug\n  }\n}\n```\n\n### Why `ahead` matters\n\nData written by a newer build than the one reading it **cannot be migrated downward** — the information genuinely is not there. This is not hypothetical: a colleague saves from the new deploy while your tab is stale, or a user's phone updates before their laptop.\n\nCollapsing this into `null` means every caller guesses, and the guess is almost always \"discard it\" — which destroys perfectly good data that merely came from the future.\n\n---\n\n## Versioned storage\n\nThe failure this prevents is the quiet one:\n\n```ts\n// Before: an assertion, not a check.\nreturn JSON.parse(raw) as WeeklyContent;\n```\n\nThe moment the model changes, every cached record on every user's machine has the old shape while being *typed* as the new one. You get `undefined` deep inside a renderer instead of a clean failure at the boundary.\n\n```ts\nimport { createVersionedStore, webStorageDriver } from '@braidlabs/skew';\n\nconst drafts = createVersionedStore(WeeklyContent, {\n  driver: webStorageDriver('local'),\n  buildId: BUILD_ID,\n  onReadFailure: (key, failure) => telemetry.warn('stale draft', { key, ...failure }),\n  rewriteOnRead: true,   // read-repair: persist migrated records at the current version\n});\n\nawait drafts.set('2026-12-06', content);\nconst result = await drafts.get('2026-12-06');   // migrated on the way out\n\n// Sync read for signal/hook initialisers — no flash of empty state.\nconst immediate = drafts.peek('2026-12-06');     // null on async drivers\n```\n\nDrivers: `memoryDriver()`, `webStorageDriver('local' | 'session')`, or implement `StorageDriver` for IndexedDB or anything else. Web Storage degrades to memory automatically under Safari private mode, disabled cookies, and SSR, and swallows quota errors on write — a failing cache should never break a save the user asked for.\n\n### Adopting on existing data\n\nYou do not need a backfill. Data with no envelope is treated as **v1**, so declare your *current* shape as the base and records upgrade themselves as users touch them.\n\n```ts\nexport const Parish = versioned<CurrentShape>('parish');   // v1, adopts everything\n```\n\n### Retiring old versions (cleanup)\n\nChains are **append-only at the top and trim-only at the bottom**. A step\n`n → n+1` is deletable only when no data enveloped at ≤ n can still reach a\nreader — so cleanup is a sequence, not an edit:\n\n1. **Instrument**: watch `result.migratedFrom` in telemetry. You can only\n   delete steps you can prove are idle.\n2. **Shrink the tail**: enable `rewriteOnRead` on stores (below), so each old\n   record pays its migration once, is re-persisted at the current version,\n   and drops out of the telemetry.\n3. **Trim**: re-declare the schema with the oldest surviving shape as its\n   base and delete the retired steps. Never renumber — v4 stays v4.\n\n```ts\n// before: versioned<V1>('draft').next<V2>(…).next<V3>(…).next<V4>(…)\nexport const Draft = versioned<V3>('draft', { base: 3 }).next<V4>(…);\n```\n\nReads below the floor fail with `reason: 'retired'` (plus `floor`) — a\n*policy* outcome whose remedy is discard/refetch/reset — never `gap`, which\nstill means \"a step is missing and that's a bug\". If another bundle on the\npage or a resolved contract still supplies the retired steps via the shared\nregistry, the read simply succeeds. Bare (un-enveloped) data is still assumed\nto be v1, so after a trim it surfaces as `retired`; set\n`assumeLegacyVersion: base` only if bare data is known to carry the base\nshape. `write({ as })` below the floor throws.\n\nRetire conservatively for data you cannot refetch (drafts, queued outboxes —\ndrain queues first and give users a \"too old to open\" path), aggressively for\nrefetchable caches. Steps are cheap; delete with evidence, not tidiness.\n\n---\n\n## Build identity and skew detection\n\n```ts\nimport { createVersionProbe } from '@braidlabs/skew';\n\nconst probe = createVersionProbe({\n  identity: { buildId: BUILD_ID, builtAt: BUILT_AT },\n  manifestUrl: '/skew-manifest.json',   // serve with Cache-Control: no-store\n});\n\nconst status = await probe.check();\n```\n\n| Status | Meaning | Correct response |\n|---|---|---|\n| `current` | in sync | — |\n| `staleClient` | a newer deploy exists | offer a reload |\n| `staleOrigin` | **origin is older than us** | **do not reload — it will loop** |\n| `differs` | cannot be ordered (no timestamps) | treat conservatively |\n| `unreachable` | offline or blocked | do not reload; you would land on an error page |\n\n`staleOrigin` is the case naïve implementations miss. If a CDN is serving a cached entry document or a region is lagging, reloading fetches the same stale bundle and fails again — forever. Detecting it requires comparing build *timestamps*, which is why `builtAt` is worth stamping.\n\nThe probe collapses concurrent callers onto a single request and caches for `minIntervalMs` (default 10s), so a page that fails three chunks at once still makes one network call.\n\n### The manifest\n\nEmit it at build time and serve it uncached:\n\n```json\n{\n  \"buildId\": \"a1b2c3d\",\n  \"builtAt\": \"2026-08-07T10:14:00Z\",\n  \"modules\": { \"admin.routes\": { \"file\": \"chunk-XYZ789.js\" } }\n}\n```\n\n`modules` is optional. With it, `moduleWasRemoved(manifest, id)` distinguishes \"this route moved\" from \"this route was deleted\" — which is the difference between reloading and redirecting to a fallback.\n\n---\n\n## API\n\n| Export | Purpose |\n|---|---|\n| `versioned<T>(name, options?)` | Begin a schema declaration (`base` retires older versions) |\n| `VersionedSchema.next<TNext>(desc?, fn)` | Add a version |\n| `emitSkewTrace` / `SKEW_DEVTOOLS_HOOK` | Devtools trace hook — reads/writes emit events when a hook is installed |\n| `.read(raw)` / `.write(value, buildId?)` | Migrate in / envelope out |\n| `isEnvelope(v)` / `peekVersion(v)` | Inspect without migrating |\n| `createVersionedStore(schema, opts)` | Persistence with migration |\n| `memoryDriver()` / `webStorageDriver()` | Built-in drivers |\n| `createVersionProbe(opts)` | Build comparison against an origin |\n| `compareBuilds(identity, manifest)` | Pure classification |\n| `moduleWasRemoved(manifest, id)` | Route-deleted detection |\n| `isOk` / `isErr` / `valueOr` / `mapResult` | Result helpers |\n\n---\n\n## Related packages\n\n`@braidlabs/skew` is consumed by, but never requires, the framework bindings:\n\n- `@braidlabs/angular-router` — chunk-load recovery for the Angular router\n- `@braidlabs/angular-data` — versioned cache and durable mutation outbox\n- `@braidlabs/angular-workflow` — durable multi-step flows\n- `@braidlabs/react-*` — the same, for React\n- `@braidlabs/node` / `@braidlabs/nest` — server-side negotiation and manifest serving\n\nEach is independently installable. None requires the others.\n","readmeFilename":"README.md","_rev":"1-9bd8e6521afc9e1a16b5bd81deb999fe"}