@braidlabs/skew 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 +247 -0
- package/package.json +37 -0
- package/src/index.d.ts +12 -0
- package/src/index.js +14 -0
- package/src/index.js.map +1 -0
- package/src/lib/context.d.ts +26 -0
- package/src/lib/context.js +5 -0
- package/src/lib/context.js.map +1 -0
- package/src/lib/devtools.d.ts +51 -0
- package/src/lib/devtools.js +31 -0
- package/src/lib/devtools.js.map +1 -0
- package/src/lib/disabled.d.ts +48 -0
- package/src/lib/disabled.js +59 -0
- package/src/lib/disabled.js.map +1 -0
- package/src/lib/fingerprint.d.ts +32 -0
- package/src/lib/fingerprint.js +62 -0
- package/src/lib/fingerprint.js.map +1 -0
- package/src/lib/http.d.ts +70 -0
- package/src/lib/http.js +76 -0
- package/src/lib/http.js.map +1 -0
- package/src/lib/identity.d.ts +120 -0
- package/src/lib/identity.js +126 -0
- package/src/lib/identity.js.map +1 -0
- package/src/lib/lens.d.ts +133 -0
- package/src/lib/lens.js +337 -0
- package/src/lib/lens.js.map +1 -0
- package/src/lib/list.d.ts +18 -0
- package/src/lib/list.js +49 -0
- package/src/lib/list.js.map +1 -0
- package/src/lib/registry.d.ts +77 -0
- package/src/lib/registry.js +80 -0
- package/src/lib/registry.js.map +1 -0
- package/src/lib/result.d.ts +103 -0
- package/src/lib/result.js +78 -0
- package/src/lib/result.js.map +1 -0
- package/src/lib/storage.d.ts +86 -0
- package/src/lib/storage.js +190 -0
- package/src/lib/storage.js.map +1 -0
- package/src/lib/versioned.d.ts +242 -0
- package/src/lib/versioned.js +275 -0
- package/src/lib/versioned.js.map +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# @braidlabs/skew
|
|
2
|
+
|
|
3
|
+
Framework-agnostic primitives for surviving version skew.
|
|
4
|
+
|
|
5
|
+
No dependencies. No framework. Works in browsers, Node, workers, and Deno.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
Four failures that look unrelated are the same failure:
|
|
12
|
+
|
|
13
|
+
| Boundary | What crosses it | Symptom |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Client ↔ origin | a lazy chunk request | `ChunkLoadError` after a deploy |
|
|
16
|
+
| Client ↔ API | a queued mutation, flushed later | 400s, or silent data corruption |
|
|
17
|
+
| Host ↔ fragment | props and events | mismatched contracts at runtime |
|
|
18
|
+
| **Past self ↔ present self** | a persisted draft or cache | `undefined` deep in a renderer |
|
|
19
|
+
|
|
20
|
+
In every case, two independently-versioned parties met at a boundary and had no way to discover that they disagreed.
|
|
21
|
+
|
|
22
|
+
This 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.**
|
|
23
|
+
|
|
24
|
+
The 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.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install @braidlabs/skew
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Versioned schemas
|
|
37
|
+
|
|
38
|
+
Declare a type's current version, its history, and the functions that move data between them in one place.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { versioned } from '@braidlabs/skew';
|
|
42
|
+
|
|
43
|
+
// Snapshot shapes — frozen copies, never your live application types.
|
|
44
|
+
interface V1 { id: string; themeQuote?: { text: string } }
|
|
45
|
+
interface V2 { id: string; scriptureOfWeek?: { text: string } }
|
|
46
|
+
type V3 = V2 & { orderOfWorship: { setting: string; hymns: string[] } };
|
|
47
|
+
|
|
48
|
+
export const WeeklyContent = versioned<V1>('weekly-content')
|
|
49
|
+
.next<V2>('rename themeQuote to scriptureOfWeek', (p) => ({
|
|
50
|
+
id: p.id,
|
|
51
|
+
scriptureOfWeek: p.themeQuote,
|
|
52
|
+
}))
|
|
53
|
+
.next<V3>('introduce orderOfWorship', (p) => ({
|
|
54
|
+
...p,
|
|
55
|
+
orderOfWorship: { setting: '', hymns: [] },
|
|
56
|
+
}));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Reading migrates forward automatically:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const result = WeeklyContent.read(rawFromFirestore);
|
|
63
|
+
|
|
64
|
+
if (result.ok) {
|
|
65
|
+
render(result.value); // always the current shape
|
|
66
|
+
if (result.migratedFrom !== null) {
|
|
67
|
+
console.info(`upgraded from v${result.migratedFrom}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each `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.
|
|
73
|
+
|
|
74
|
+
### The one rule
|
|
75
|
+
|
|
76
|
+
**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.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Results, not exceptions
|
|
81
|
+
|
|
82
|
+
`read()` returns a discriminated result, because the failure modes need *different* remedies:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const result = WeeklyContent.read(raw);
|
|
86
|
+
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
switch (result.reason) {
|
|
89
|
+
case 'ahead': return refetchFromServer(); // written by a NEWER build
|
|
90
|
+
case 'gap': return reportBug(result); // missing migration step
|
|
91
|
+
case 'invalid': return discardAndRefetch();
|
|
92
|
+
case 'threw': return reportBug(result); // a migration failed
|
|
93
|
+
case 'retired': return discardAndRefetch(); // below the declared floor — policy, not a bug
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Why `ahead` matters
|
|
99
|
+
|
|
100
|
+
Data 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.
|
|
101
|
+
|
|
102
|
+
Collapsing 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.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Versioned storage
|
|
107
|
+
|
|
108
|
+
The failure this prevents is the quiet one:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// Before: an assertion, not a check.
|
|
112
|
+
return JSON.parse(raw) as WeeklyContent;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The 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.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { createVersionedStore, webStorageDriver } from '@braidlabs/skew';
|
|
119
|
+
|
|
120
|
+
const drafts = createVersionedStore(WeeklyContent, {
|
|
121
|
+
driver: webStorageDriver('local'),
|
|
122
|
+
buildId: BUILD_ID,
|
|
123
|
+
onReadFailure: (key, failure) => telemetry.warn('stale draft', { key, ...failure }),
|
|
124
|
+
rewriteOnRead: true, // read-repair: persist migrated records at the current version
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await drafts.set('2026-12-06', content);
|
|
128
|
+
const result = await drafts.get('2026-12-06'); // migrated on the way out
|
|
129
|
+
|
|
130
|
+
// Sync read for signal/hook initialisers — no flash of empty state.
|
|
131
|
+
const immediate = drafts.peek('2026-12-06'); // null on async drivers
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Drivers: `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.
|
|
135
|
+
|
|
136
|
+
### Adopting on existing data
|
|
137
|
+
|
|
138
|
+
You 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.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
export const Parish = versioned<CurrentShape>('parish'); // v1, adopts everything
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Retiring old versions (cleanup)
|
|
145
|
+
|
|
146
|
+
Chains are **append-only at the top and trim-only at the bottom**. A step
|
|
147
|
+
`n → n+1` is deletable only when no data enveloped at ≤ n can still reach a
|
|
148
|
+
reader — so cleanup is a sequence, not an edit:
|
|
149
|
+
|
|
150
|
+
1. **Instrument**: watch `result.migratedFrom` in telemetry. You can only
|
|
151
|
+
delete steps you can prove are idle.
|
|
152
|
+
2. **Shrink the tail**: enable `rewriteOnRead` on stores (below), so each old
|
|
153
|
+
record pays its migration once, is re-persisted at the current version,
|
|
154
|
+
and drops out of the telemetry.
|
|
155
|
+
3. **Trim**: re-declare the schema with the oldest surviving shape as its
|
|
156
|
+
base and delete the retired steps. Never renumber — v4 stays v4.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// before: versioned<V1>('draft').next<V2>(…).next<V3>(…).next<V4>(…)
|
|
160
|
+
export const Draft = versioned<V3>('draft', { base: 3 }).next<V4>(…);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Reads below the floor fail with `reason: 'retired'` (plus `floor`) — a
|
|
164
|
+
*policy* outcome whose remedy is discard/refetch/reset — never `gap`, which
|
|
165
|
+
still means "a step is missing and that's a bug". If another bundle on the
|
|
166
|
+
page or a resolved contract still supplies the retired steps via the shared
|
|
167
|
+
registry, the read simply succeeds. Bare (un-enveloped) data is still assumed
|
|
168
|
+
to be v1, so after a trim it surfaces as `retired`; set
|
|
169
|
+
`assumeLegacyVersion: base` only if bare data is known to carry the base
|
|
170
|
+
shape. `write({ as })` below the floor throws.
|
|
171
|
+
|
|
172
|
+
Retire conservatively for data you cannot refetch (drafts, queued outboxes —
|
|
173
|
+
drain queues first and give users a "too old to open" path), aggressively for
|
|
174
|
+
refetchable caches. Steps are cheap; delete with evidence, not tidiness.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Build identity and skew detection
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { createVersionProbe } from '@braidlabs/skew';
|
|
182
|
+
|
|
183
|
+
const probe = createVersionProbe({
|
|
184
|
+
identity: { buildId: BUILD_ID, builtAt: BUILT_AT },
|
|
185
|
+
manifestUrl: '/skew-manifest.json', // serve with Cache-Control: no-store
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const status = await probe.check();
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Status | Meaning | Correct response |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `current` | in sync | — |
|
|
194
|
+
| `staleClient` | a newer deploy exists | offer a reload |
|
|
195
|
+
| `staleOrigin` | **origin is older than us** | **do not reload — it will loop** |
|
|
196
|
+
| `differs` | cannot be ordered (no timestamps) | treat conservatively |
|
|
197
|
+
| `unreachable` | offline or blocked | do not reload; you would land on an error page |
|
|
198
|
+
|
|
199
|
+
`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.
|
|
200
|
+
|
|
201
|
+
The 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.
|
|
202
|
+
|
|
203
|
+
### The manifest
|
|
204
|
+
|
|
205
|
+
Emit it at build time and serve it uncached:
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"buildId": "a1b2c3d",
|
|
210
|
+
"builtAt": "2026-08-07T10:14:00Z",
|
|
211
|
+
"modules": { "admin.routes": { "file": "chunk-XYZ789.js" } }
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`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.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## API
|
|
220
|
+
|
|
221
|
+
| Export | Purpose |
|
|
222
|
+
|---|---|
|
|
223
|
+
| `versioned<T>(name, options?)` | Begin a schema declaration (`base` retires older versions) |
|
|
224
|
+
| `VersionedSchema.next<TNext>(desc?, fn)` | Add a version |
|
|
225
|
+
| `emitSkewTrace` / `SKEW_DEVTOOLS_HOOK` | Devtools trace hook — reads/writes emit events when a hook is installed |
|
|
226
|
+
| `.read(raw)` / `.write(value, buildId?)` | Migrate in / envelope out |
|
|
227
|
+
| `isEnvelope(v)` / `peekVersion(v)` | Inspect without migrating |
|
|
228
|
+
| `createVersionedStore(schema, opts)` | Persistence with migration |
|
|
229
|
+
| `memoryDriver()` / `webStorageDriver()` | Built-in drivers |
|
|
230
|
+
| `createVersionProbe(opts)` | Build comparison against an origin |
|
|
231
|
+
| `compareBuilds(identity, manifest)` | Pure classification |
|
|
232
|
+
| `moduleWasRemoved(manifest, id)` | Route-deleted detection |
|
|
233
|
+
| `isOk` / `isErr` / `valueOr` / `mapResult` | Result helpers |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Related packages
|
|
238
|
+
|
|
239
|
+
`@braidlabs/skew` is consumed by, but never requires, the framework bindings:
|
|
240
|
+
|
|
241
|
+
- `@braidlabs/angular-router` — chunk-load recovery for the Angular router
|
|
242
|
+
- `@braidlabs/angular-data` — versioned cache and durable mutation outbox
|
|
243
|
+
- `@braidlabs/angular-workflow` — durable multi-step flows
|
|
244
|
+
- `@braidlabs/react-*` — the same, for React
|
|
245
|
+
- `@braidlabs/node` / `@braidlabs/nest` — server-side negotiation and manifest serving
|
|
246
|
+
|
|
247
|
+
Each is independently installable. None requires the others.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@braidlabs/skew",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Framework-agnostic primitives for surviving version skew: versioned envelopes, migration chains, build identity and negotiation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"version-skew",
|
|
7
|
+
"migration",
|
|
8
|
+
"schema-versioning",
|
|
9
|
+
"deployment",
|
|
10
|
+
"offline"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.js",
|
|
16
|
+
"types": "./src/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./src/index.d.ts",
|
|
20
|
+
"default": "./src/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/braidjs/braid.git",
|
|
30
|
+
"directory": "libs/skew"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/braidjs/braid#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/braidjs/braid/issues"
|
|
35
|
+
},
|
|
36
|
+
"module": "./src/index.js"
|
|
37
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './lib/result.js';
|
|
2
|
+
export * from './lib/context.js';
|
|
3
|
+
export * from './lib/versioned.js';
|
|
4
|
+
export * from './lib/lens.js';
|
|
5
|
+
export * from './lib/list.js';
|
|
6
|
+
export * from './lib/registry.js';
|
|
7
|
+
export * from './lib/fingerprint.js';
|
|
8
|
+
export * from './lib/http.js';
|
|
9
|
+
export * from './lib/identity.js';
|
|
10
|
+
export * from './lib/storage.js';
|
|
11
|
+
export * from './lib/devtools.js';
|
|
12
|
+
export * from './lib/disabled.js';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './lib/result.js';
|
|
2
|
+
export * from './lib/context.js';
|
|
3
|
+
export * from './lib/versioned.js';
|
|
4
|
+
export * from './lib/lens.js';
|
|
5
|
+
export * from './lib/list.js';
|
|
6
|
+
export * from './lib/registry.js';
|
|
7
|
+
export * from './lib/fingerprint.js';
|
|
8
|
+
export * from './lib/http.js';
|
|
9
|
+
export * from './lib/identity.js';
|
|
10
|
+
export * from './lib/storage.js';
|
|
11
|
+
export * from './lib/devtools.js';
|
|
12
|
+
// Not public API. Undocumented on purpose — read the module comment first.
|
|
13
|
+
export * from './lib/disabled.js';
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
package/src/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../libs/skew/src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,eAAe,CAAC;AAC9B,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAElC,2EAA2E;AAC3E,cAAc,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context handed to every migration function.
|
|
3
|
+
*
|
|
4
|
+
* Migrations must be deterministic: the same input must produce the same
|
|
5
|
+
* output, or replays, tests, memoization, and fingerprinting all quietly
|
|
6
|
+
* break. Anything a migration needs from the outside world therefore arrives
|
|
7
|
+
* through this context — never through `new Date()` or `Math.random()` inside
|
|
8
|
+
* the migration body — so a test (or a replay) can pin it.
|
|
9
|
+
*/
|
|
10
|
+
export interface MigrationContext {
|
|
11
|
+
/**
|
|
12
|
+
* The clock. A migration that needs "now" (an `asOf` stamp, a derived
|
|
13
|
+
* timestamp) must take it from here so two runs over the same input can be
|
|
14
|
+
* made to agree.
|
|
15
|
+
*/
|
|
16
|
+
now(): Date;
|
|
17
|
+
/**
|
|
18
|
+
* A stable seed for anything that must be unique-but-reproducible — a
|
|
19
|
+
* derived idempotency key, a placeholder reference. Callers that retry a
|
|
20
|
+
* migration (an outbox, a replay) pass the same seed each attempt so the
|
|
21
|
+
* derived value stays identical across attempts.
|
|
22
|
+
*/
|
|
23
|
+
readonly seed?: string;
|
|
24
|
+
}
|
|
25
|
+
/** The context used when a caller supplies none: the real clock, no seed. */
|
|
26
|
+
export declare const defaultMigrationContext: MigrationContext;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.js","sourceRoot":"","sources":["../../../../../libs/skew/src/lib/context.ts"],"names":[],"mappings":"AAyBA,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAqB;IACvD,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE;CACtB,CAAC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devtools trace hook.
|
|
3
|
+
*
|
|
4
|
+
* Follows the React-devtools pattern: a tool that wants to observe skew
|
|
5
|
+
* activity installs a global hook *before* the app boots, and the library
|
|
6
|
+
* emits one event per `read()` / `write()` into it. When no hook is
|
|
7
|
+
* installed (production, tests, every app that never opens the tools) the
|
|
8
|
+
* cost is one property lookup per call and nothing is retained.
|
|
9
|
+
*
|
|
10
|
+
* Events deliberately carry versions, reasons, and paths — never payloads.
|
|
11
|
+
* Payload capture is a tool-side, opt-in concern (drafts contain user data);
|
|
12
|
+
* the library's default must be safe to leave enabled everywhere.
|
|
13
|
+
*/
|
|
14
|
+
/** One observed schema operation. */
|
|
15
|
+
export interface SkewTraceEvent {
|
|
16
|
+
readonly kind: 'read' | 'write';
|
|
17
|
+
/** Schema / contract name. */
|
|
18
|
+
readonly schema: string;
|
|
19
|
+
/**
|
|
20
|
+
* Version the data claimed (envelope `v`, or the assumed version for bare
|
|
21
|
+
* data). For writes: the version of the value being written (the chain's
|
|
22
|
+
* current version).
|
|
23
|
+
*/
|
|
24
|
+
readonly from: number;
|
|
25
|
+
/**
|
|
26
|
+
* The reader's current version (reads), or the target version being
|
|
27
|
+
* written at (writes — differs from `from` on a `write({ as })`).
|
|
28
|
+
*/
|
|
29
|
+
readonly to: number;
|
|
30
|
+
/** Read outcome. Always `true` for writes — a failed write throws. */
|
|
31
|
+
readonly ok: boolean;
|
|
32
|
+
/** Failure reason, when `ok` is false. */
|
|
33
|
+
readonly reason?: string;
|
|
34
|
+
readonly migratedFrom?: number | null;
|
|
35
|
+
readonly downgradedFrom?: number | null;
|
|
36
|
+
readonly derivedPaths?: readonly string[];
|
|
37
|
+
readonly lossyPaths?: readonly string[];
|
|
38
|
+
/** Epoch milliseconds. */
|
|
39
|
+
readonly ts: number;
|
|
40
|
+
}
|
|
41
|
+
/** What a devtools implementation installs at `globalThis.__SKEW_DEVTOOLS_HOOK__`. */
|
|
42
|
+
export interface SkewDevtoolsHook {
|
|
43
|
+
emit(event: SkewTraceEvent): void;
|
|
44
|
+
}
|
|
45
|
+
/** The global property devtools implementations install themselves under. */
|
|
46
|
+
export declare const SKEW_DEVTOOLS_HOOK = "__SKEW_DEVTOOLS_HOOK__";
|
|
47
|
+
/**
|
|
48
|
+
* Emits a trace event into the installed hook, if any. A throwing hook is
|
|
49
|
+
* swallowed — observability must never break the app it observes.
|
|
50
|
+
*/
|
|
51
|
+
export declare function emitSkewTrace(event: SkewTraceEvent): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devtools trace hook.
|
|
3
|
+
*
|
|
4
|
+
* Follows the React-devtools pattern: a tool that wants to observe skew
|
|
5
|
+
* activity installs a global hook *before* the app boots, and the library
|
|
6
|
+
* emits one event per `read()` / `write()` into it. When no hook is
|
|
7
|
+
* installed (production, tests, every app that never opens the tools) the
|
|
8
|
+
* cost is one property lookup per call and nothing is retained.
|
|
9
|
+
*
|
|
10
|
+
* Events deliberately carry versions, reasons, and paths — never payloads.
|
|
11
|
+
* Payload capture is a tool-side, opt-in concern (drafts contain user data);
|
|
12
|
+
* the library's default must be safe to leave enabled everywhere.
|
|
13
|
+
*/
|
|
14
|
+
/** The global property devtools implementations install themselves under. */
|
|
15
|
+
export const SKEW_DEVTOOLS_HOOK = '__SKEW_DEVTOOLS_HOOK__';
|
|
16
|
+
/**
|
|
17
|
+
* Emits a trace event into the installed hook, if any. A throwing hook is
|
|
18
|
+
* swallowed — observability must never break the app it observes.
|
|
19
|
+
*/
|
|
20
|
+
export function emitSkewTrace(event) {
|
|
21
|
+
const hook = globalThis[SKEW_DEVTOOLS_HOOK];
|
|
22
|
+
if (!hook || typeof hook.emit !== 'function')
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
hook.emit(event);
|
|
26
|
+
}
|
|
27
|
+
catch (_a) {
|
|
28
|
+
// The hook is diagnostic machinery; its failures are its own.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=devtools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"devtools.js","sourceRoot":"","sources":["../../../../../libs/skew/src/lib/devtools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAmCH,6EAA6E;AAC7E,MAAM,CAAC,MAAM,kBAAkB,GAAG,wBAAwB,CAAC;AAE3D;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,KAAqB;IACjD,MAAM,IAAI,GAAI,UAAsC,CAAC,kBAAkB,CAE1D,CAAC;IACd,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,IAAI,KAAK,UAAU;QAAE,OAAO;IACrD,IAAI,CAAC;QACH,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACnB,CAAC;IAAC,WAAM,CAAC;QACP,8DAA8D;IAChE,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ NOT PUBLIC API. Undocumented on purpose. Do not use in an application.
|
|
3
|
+
*
|
|
4
|
+
* A process-wide switch that makes every `@braidlabs` package behave as though it
|
|
5
|
+
* were not installed:
|
|
6
|
+
*
|
|
7
|
+
* - stores write bare payloads and read them back with no envelope check,
|
|
8
|
+
* which is `JSON.parse(raw) as T` — an assertion, not a check
|
|
9
|
+
* - `versioned().read()` returns whatever it was handed, unmigrated
|
|
10
|
+
* - `lazy()` stops retrying
|
|
11
|
+
* - recovery stops classifying and takes no action
|
|
12
|
+
*
|
|
13
|
+
* ## Why this exists
|
|
14
|
+
*
|
|
15
|
+
* The demos need to show the failure modes these packages prevent, and the only
|
|
16
|
+
* trustworthy way to show a failure is to actually produce one. A screenshot of
|
|
17
|
+
* a hand-written "before" snippet proves nothing; running the same scenario
|
|
18
|
+
* with the protections removed proves it exactly.
|
|
19
|
+
*
|
|
20
|
+
* ## Why it is not documented
|
|
21
|
+
*
|
|
22
|
+
* There is no legitimate production use. Every reason to reach for it — "just
|
|
23
|
+
* while I debug this", "only in staging" — ends with a flag nobody remembers
|
|
24
|
+
* set on a deployment nobody re-checks, and the failures it re-enables are
|
|
25
|
+
* silent ones: a draft read at the wrong shape, a mutation replayed against a
|
|
26
|
+
* contract that moved. Those surface as `undefined` in a renderer weeks later,
|
|
27
|
+
* far from the switch that caused them.
|
|
28
|
+
*
|
|
29
|
+
* So it ships, because the demos are more valuable than the purity of the
|
|
30
|
+
* export list, but it stays out of the README, out of the package docs, and out
|
|
31
|
+
* of every example. If you found this by reading the source: that was the deal.
|
|
32
|
+
* Turning it on logs a warning for the same reason.
|
|
33
|
+
*
|
|
34
|
+
* ## Why it is global mutable state
|
|
35
|
+
*
|
|
36
|
+
* `@braidlabs/skew` has no framework and no dependencies, so there is no injector to
|
|
37
|
+
* read from. A module-level flag is the only mechanism available to it. The
|
|
38
|
+
* Angular packages wrap this in `provideSkewDisabled()` so an application at
|
|
39
|
+
* least *looks* like it is configuring something, but the state is here.
|
|
40
|
+
*
|
|
41
|
+
* One consequence worth knowing: under Module Federation this is per *bundle
|
|
42
|
+
* instance*, so it only behaves as one switch when `@braidlabs/skew` is genuinely
|
|
43
|
+
* shared as a singleton. Two copies of core means two flags.
|
|
44
|
+
*/
|
|
45
|
+
/** @internal Not public API — see the module comment. */
|
|
46
|
+
export declare function setSkewDisabled(next: boolean): void;
|
|
47
|
+
/** @internal Not public API — see the module comment. */
|
|
48
|
+
export declare function isSkewDisabled(): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ NOT PUBLIC API. Undocumented on purpose. Do not use in an application.
|
|
3
|
+
*
|
|
4
|
+
* A process-wide switch that makes every `@braidlabs` package behave as though it
|
|
5
|
+
* were not installed:
|
|
6
|
+
*
|
|
7
|
+
* - stores write bare payloads and read them back with no envelope check,
|
|
8
|
+
* which is `JSON.parse(raw) as T` — an assertion, not a check
|
|
9
|
+
* - `versioned().read()` returns whatever it was handed, unmigrated
|
|
10
|
+
* - `lazy()` stops retrying
|
|
11
|
+
* - recovery stops classifying and takes no action
|
|
12
|
+
*
|
|
13
|
+
* ## Why this exists
|
|
14
|
+
*
|
|
15
|
+
* The demos need to show the failure modes these packages prevent, and the only
|
|
16
|
+
* trustworthy way to show a failure is to actually produce one. A screenshot of
|
|
17
|
+
* a hand-written "before" snippet proves nothing; running the same scenario
|
|
18
|
+
* with the protections removed proves it exactly.
|
|
19
|
+
*
|
|
20
|
+
* ## Why it is not documented
|
|
21
|
+
*
|
|
22
|
+
* There is no legitimate production use. Every reason to reach for it — "just
|
|
23
|
+
* while I debug this", "only in staging" — ends with a flag nobody remembers
|
|
24
|
+
* set on a deployment nobody re-checks, and the failures it re-enables are
|
|
25
|
+
* silent ones: a draft read at the wrong shape, a mutation replayed against a
|
|
26
|
+
* contract that moved. Those surface as `undefined` in a renderer weeks later,
|
|
27
|
+
* far from the switch that caused them.
|
|
28
|
+
*
|
|
29
|
+
* So it ships, because the demos are more valuable than the purity of the
|
|
30
|
+
* export list, but it stays out of the README, out of the package docs, and out
|
|
31
|
+
* of every example. If you found this by reading the source: that was the deal.
|
|
32
|
+
* Turning it on logs a warning for the same reason.
|
|
33
|
+
*
|
|
34
|
+
* ## Why it is global mutable state
|
|
35
|
+
*
|
|
36
|
+
* `@braidlabs/skew` has no framework and no dependencies, so there is no injector to
|
|
37
|
+
* read from. A module-level flag is the only mechanism available to it. The
|
|
38
|
+
* Angular packages wrap this in `provideSkewDisabled()` so an application at
|
|
39
|
+
* least *looks* like it is configuring something, but the state is here.
|
|
40
|
+
*
|
|
41
|
+
* One consequence worth knowing: under Module Federation this is per *bundle
|
|
42
|
+
* instance*, so it only behaves as one switch when `@braidlabs/skew` is genuinely
|
|
43
|
+
* shared as a singleton. Two copies of core means two flags.
|
|
44
|
+
*/
|
|
45
|
+
let disabled = false;
|
|
46
|
+
/** @internal Not public API — see the module comment. */
|
|
47
|
+
export function setSkewDisabled(next) {
|
|
48
|
+
if (next && !disabled) {
|
|
49
|
+
console.warn('[skew] PROTECTIONS DISABLED. Envelopes, migrations, retries and ' +
|
|
50
|
+
'recovery are all inert. This is a demo/debugging switch and is not ' +
|
|
51
|
+
'supported in production.');
|
|
52
|
+
}
|
|
53
|
+
disabled = next;
|
|
54
|
+
}
|
|
55
|
+
/** @internal Not public API — see the module comment. */
|
|
56
|
+
export function isSkewDisabled() {
|
|
57
|
+
return disabled;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=disabled.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"disabled.js","sourceRoot":"","sources":["../../../../../libs/skew/src/lib/disabled.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,IAAI,QAAQ,GAAG,KAAK,CAAC;AAErB,yDAAyD;AACzD,MAAM,UAAU,eAAe,CAAC,IAAa;IAC3C,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QACtB,OAAO,CAAC,IAAI,CACV,kEAAkE;YAChE,qEAAqE;YACrE,0BAA0B,CAC7B,CAAC;IACJ,CAAC;IACD,QAAQ,GAAG,IAAI,CAAC;AAClB,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,cAAc;IAC5B,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content fingerprints for schemas and their steps.
|
|
3
|
+
*
|
|
4
|
+
* Two independently built bundles can declare a chain under the same contract
|
|
5
|
+
* name. When their declarations *agree*, that duplication is harmless; when
|
|
6
|
+
* they disagree — same name, same version numbers, different meaning — the
|
|
7
|
+
* result is the silent kind of corruption nothing else in the system can see.
|
|
8
|
+
* A fingerprint makes "do these two declarations agree?" a cheap, answerable
|
|
9
|
+
* question.
|
|
10
|
+
*
|
|
11
|
+
* What goes into a step's fingerprint is deliberate:
|
|
12
|
+
*
|
|
13
|
+
* - For an ops step, the canonical JSON of the ops — the ops *are* the
|
|
14
|
+
* migration, so structural equality is real equality.
|
|
15
|
+
* - For a code step, the target version and the (required) description — and
|
|
16
|
+
* **not** `Function.prototype.toString()`. Two builds minify the same source
|
|
17
|
+
* differently, so function text equality would report false conflicts
|
|
18
|
+
* between builds that genuinely agree. The description is the human-owned
|
|
19
|
+
* identity of a code step; that is one of the reasons it is required.
|
|
20
|
+
*/
|
|
21
|
+
import type { MigrationStep } from './versioned.js';
|
|
22
|
+
/** FNV-1a, 32-bit, hex output. Small, stable, dependency-free. */
|
|
23
|
+
export declare function fnv1a(text: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* JSON with object keys sorted at every level, so logically identical
|
|
26
|
+
* documents always serialize identically.
|
|
27
|
+
*/
|
|
28
|
+
export declare function canonicalJson(value: unknown): string;
|
|
29
|
+
/** Fingerprint of a single migration step. */
|
|
30
|
+
export declare function stepFingerprint(step: MigrationStep): string;
|
|
31
|
+
/** Fingerprint of a whole chain: name, current version, and every step. */
|
|
32
|
+
export declare function chainFingerprint(name: string, version: number, steps: readonly (MigrationStep | undefined)[]): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content fingerprints for schemas and their steps.
|
|
3
|
+
*
|
|
4
|
+
* Two independently built bundles can declare a chain under the same contract
|
|
5
|
+
* name. When their declarations *agree*, that duplication is harmless; when
|
|
6
|
+
* they disagree — same name, same version numbers, different meaning — the
|
|
7
|
+
* result is the silent kind of corruption nothing else in the system can see.
|
|
8
|
+
* A fingerprint makes "do these two declarations agree?" a cheap, answerable
|
|
9
|
+
* question.
|
|
10
|
+
*
|
|
11
|
+
* What goes into a step's fingerprint is deliberate:
|
|
12
|
+
*
|
|
13
|
+
* - For an ops step, the canonical JSON of the ops — the ops *are* the
|
|
14
|
+
* migration, so structural equality is real equality.
|
|
15
|
+
* - For a code step, the target version and the (required) description — and
|
|
16
|
+
* **not** `Function.prototype.toString()`. Two builds minify the same source
|
|
17
|
+
* differently, so function text equality would report false conflicts
|
|
18
|
+
* between builds that genuinely agree. The description is the human-owned
|
|
19
|
+
* identity of a code step; that is one of the reasons it is required.
|
|
20
|
+
*/
|
|
21
|
+
/** FNV-1a, 32-bit, hex output. Small, stable, dependency-free. */
|
|
22
|
+
export function fnv1a(text) {
|
|
23
|
+
let hash = 0x811c9dc5;
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
hash ^= text.charCodeAt(i);
|
|
26
|
+
hash = Math.imul(hash, 0x01000193);
|
|
27
|
+
}
|
|
28
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* JSON with object keys sorted at every level, so logically identical
|
|
32
|
+
* documents always serialize identically.
|
|
33
|
+
*/
|
|
34
|
+
export function canonicalJson(value) {
|
|
35
|
+
return JSON.stringify(sortKeys(value));
|
|
36
|
+
}
|
|
37
|
+
function sortKeys(value) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.map(sortKeys);
|
|
40
|
+
if (typeof value === 'object' && value !== null) {
|
|
41
|
+
const source = value;
|
|
42
|
+
const sorted = {};
|
|
43
|
+
for (const key of Object.keys(source).sort()) {
|
|
44
|
+
sorted[key] = sortKeys(source[key]);
|
|
45
|
+
}
|
|
46
|
+
return sorted;
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
/** Fingerprint of a single migration step. */
|
|
51
|
+
export function stepFingerprint(step) {
|
|
52
|
+
const body = step.ops ? canonicalJson(step.ops) : 'code';
|
|
53
|
+
return fnv1a(`${step.to}:${step.description}:${body}`);
|
|
54
|
+
}
|
|
55
|
+
/** Fingerprint of a whole chain: name, current version, and every step. */
|
|
56
|
+
export function chainFingerprint(name, version, steps) {
|
|
57
|
+
// A hole (a known-of but not-runnable step) fingerprints as such — two
|
|
58
|
+
// builds missing the same implementation still agree about the chain.
|
|
59
|
+
const parts = steps.map((s) => (s ? stepFingerprint(s) : 'hole')).join('.');
|
|
60
|
+
return fnv1a(`${name}@${version}:${parts}`);
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=fingerprint.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fingerprint.js","sourceRoot":"","sources":["../../../../../libs/skew/src/lib/fingerprint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,kEAAkE;AAClE,MAAM,UAAU,KAAK,CAAC,IAAY;IAChC,IAAI,IAAI,GAAG,UAAU,CAAC;IACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC3B,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;AACpD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACrD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QAChD,MAAM,MAAM,GAAG,KAAgC,CAAC;QAChD,MAAM,MAAM,GAA4B,EAAE,CAAC;QAC3C,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;YAC7C,MAAM,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACtC,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,8CAA8C;AAC9C,MAAM,UAAU,eAAe,CAAC,IAAmB;IACjD,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACzD,OAAO,KAAK,CAAC,GAAG,IAAI,CAAC,EAAE,IAAI,IAAI,CAAC,WAAW,IAAI,IAAI,EAAE,CAAC,CAAC;AACzD,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,gBAAgB,CAC9B,IAAY,EACZ,OAAe,EACf,KAA6C;IAE7C,uEAAuE;IACvE,sEAAsE;IACtE,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC5E,OAAO,KAAK,CAAC,GAAG,IAAI,IAAI,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC;AAC9C,CAAC"}
|