Skip to content

Commit 8ed083b

Browse files
authored
Merge pull request #1650 from EpicenterHQ/pr/2-materializer-factory
feat(workspace): replace markdownMaterializer with createMaterializer factory
2 parents 2a129d1 + ca6e932 commit 8ed083b

14 files changed

+1322
-438
lines changed

.agents/skills/workspace-api/SKILL.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,22 @@ export type User = InferTableRow<typeof usersTable>;
4040

4141
Every table schema must include `_v` with a number literal. The type system enforces this — passing a schema without `_v` to `defineTable()` is a compile error.
4242

43-
### Builder (Multiple Versions)
43+
### Variadic (Multiple Versions)
4444

4545
Use when you need to evolve a schema over time:
4646

4747
```typescript
48-
const posts = defineTable()
49-
.version(type({ id: 'string', title: 'string', _v: '1' }))
50-
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
51-
.migrate((row) => {
52-
switch (row._v) {
53-
case 1:
54-
return { ...row, views: 0, _v: 2 };
55-
case 2:
56-
return row;
57-
}
58-
});
48+
const posts = defineTable(
49+
type({ id: 'string', title: 'string', _v: '1' }),
50+
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
51+
).migrate((row) => {
52+
switch (row._v) {
53+
case 1:
54+
return { ...row, views: 0, _v: 2 };
55+
case 2:
56+
return row;
57+
}
58+
});
5959
```
6060

6161
## KV Stores

docs/articles/20260127T120000-static-workspace-api-guide.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ import {
2929
import { type } from 'arktype';
3030

3131
// Define table schemas with versioning
32-
const posts = defineTable()
33-
.version(type({ id: 'string', title: 'string', _v: '1' }))
34-
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
32+
const posts = defineTable(
33+
type({ id: 'string', title: 'string', _v: '1' }),
34+
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
35+
)
3536
.migrate((row) => {
3637
if (row._v === 1) return { ...row, views: 0, _v: 2 };
3738
return row;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Stop Writing Let-Then-Check—Use a Lazy Initializer
2+
3+
You've written this pattern. Everyone has. A value that's expensive to compute, only needed sometimes, so you defer it with a `let` and a null check. It works. It just feels slightly wrong every time.
4+
5+
Here's what it looked like in Epicenter's `YKeyValueLww` observer, where we needed to avoid recomputing `yarray.toArray()` and rebuilding an index map on every change event:
6+
7+
```typescript
8+
let allEntries: YKeyValueLwwEntry<T>[] | null = null;
9+
const getAllEntries = () => {
10+
allEntries ??= yarray.toArray();
11+
return allEntries;
12+
};
13+
let entryIndexMap: Map<YKeyValueLwwEntry<T>, number> | null = null;
14+
const getEntryIndex = (entry: YKeyValueLwwEntry<T>): number => {
15+
if (!entryIndexMap) {
16+
const entries = getAllEntries();
17+
entryIndexMap = new Map();
18+
for (let i = 0; i < entries.length; i++) {
19+
const indexedEntry = entries[i];
20+
if (indexedEntry) entryIndexMap.set(indexedEntry, i);
21+
}
22+
}
23+
return entryIndexMap.get(entry) ?? -1;
24+
};
25+
```
26+
27+
Two `let` variables, two null checks, one function that calls another. The logic is correct but the structure is noise. The real work—`toArray()` and building the map—is buried under bookkeeping.
28+
29+
## The Abstraction That Was Missing
30+
31+
The pattern is always the same: run `init()` once, cache the result, return it on every subsequent call. That's a function. Write it once:
32+
33+
```typescript
34+
export function lazy<T>(init: () => T): () => T {
35+
let value: T | undefined;
36+
let initialized = false;
37+
return () => {
38+
if (!initialized) {
39+
value = init();
40+
initialized = true;
41+
}
42+
return value as T;
43+
};
44+
}
45+
```
46+
47+
The `initialized` boolean is deliberate. `??=` would break if `init()` legitimately returns `undefined` or `null`—the null check would re-run the initializer on every call. The boolean flag handles those cases correctly.
48+
49+
## What the Code Looks Like After
50+
51+
```typescript
52+
const getAllEntries = lazy(() => yarray.toArray());
53+
const getEntryIndexMap = lazy(() => {
54+
const entries = getAllEntries();
55+
const map = new Map<YKeyValueLwwEntry<T>, number>();
56+
for (let i = 0; i < entries.length; i++) {
57+
const entry = entries[i];
58+
if (entry) map.set(entry, i);
59+
}
60+
return map;
61+
});
62+
const getEntryIndex = (entry: YKeyValueLwwEntry<T>): number =>
63+
getEntryIndexMap().get(entry) ?? -1;
64+
```
65+
66+
The null-tracking variables are gone. Each lazy value declares what it computes, not how it caches. `getEntryIndexMap` calls `getAllEntries()` inside its initializer—if `getAllEntries` hasn't run yet, it runs now. They compose naturally because they're just functions.
67+
68+
## Scope Is the Key Property
69+
70+
These lazy values are created inside a callback. When the callback returns, they go out of scope and get garbage collected. There's no global state, no module-level singleton, no cleanup needed. Each invocation of the observer gets its own fresh lazy values, computed on demand, discarded when done.
71+
72+
This is different from the async lazy singleton pattern, where you cache a `Promise` at module scope so an expensive async operation only runs once for the lifetime of the process. That pattern is for long-lived resources: database connections, loaded configs, initialized SDKs. This pattern is for one-shot deferred computation within a single function scope—values that are expensive enough to skip if unused, but only needed for the duration of one call.
73+
74+
The distinction matters. Reach for the async singleton when you want one instance forever. Reach for `lazy()` when you want "compute this at most once, but only if something actually asks for it."
75+
76+
The vague code smell from the let-then-check pattern was pointing at a real gap. `lazy()` fills it.

docs/articles/schema-granularity-matches-write-granularity.md

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,17 @@ If Alice edits `title` and Bob edits `views` at the same time, one of their chan
6969
Here's how it looks in practice:
7070

7171
```typescript
72-
const posts = defineTable('posts')
73-
.version(type({ id: 'string', title: 'string', _v: '1' }))
74-
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
75-
.version(
76-
type({
77-
id: 'string',
78-
title: 'string',
79-
views: 'number',
80-
tags: 'string[]',
81-
_v: '3',
82-
}),
83-
)
72+
const posts = defineTable(
73+
type({ id: 'string', title: 'string', _v: '1' }),
74+
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
75+
type({
76+
id: 'string',
77+
title: 'string',
78+
views: 'number',
79+
tags: 'string[]',
80+
_v: '3',
81+
}),
82+
)
8483
.migrate((row) => {
8584
switch (row._v) {
8685
case 1:
@@ -93,7 +92,7 @@ const posts = defineTable('posts')
9392
});
9493
```
9594

96-
The `.version()` calls register schema versions. The `.migrate()` function receives any version and normalizes to the latest.
95+
The schema versions are declared directly in `defineTable(...)`. The `.migrate()` function receives any version and normalizes to the latest.
9796

9897
Because the entire row is atomic, you're guaranteed that `row._v` tells you the exact schema of every field in that row. No ambiguity, no mixed versions.
9998

@@ -102,13 +101,10 @@ Because the entire row is atomic, you're guaranteed that `row._v` tells you the
102101
The same principle applies to key-value storage, though KV values don't need a `_v` discriminator. KV values are small enough that field presence is unambiguous:
103102

104103
```typescript
105-
const theme = defineKv('theme')
106-
.version(type({ mode: "'light' | 'dark'" }))
107-
.version(type({ mode: "'light' | 'dark' | 'system'", accentColor: 'string' }))
108-
.migrate((v) => {
109-
if (!('accentColor' in v)) return { ...v, accentColor: '#3b82f6' };
110-
return v;
111-
});
104+
const theme = defineKv(
105+
type({ mode: "'light' | 'dark' | 'system'", accentColor: 'string' }),
106+
{ mode: 'light', accentColor: '#3b82f6' },
107+
)
112108
```
113109

114110
Each KV value is an atomic blob with a coherent shape. For small objects, field presence checks are simpler than version discriminators.

docs/articles/versioned-schemas-migrate-on-read.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@ Note: ArkType automatically discriminates unions for O(1) performance. For other
130130
Every table schema includes a `_v` field — a number literal that identifies the schema version. The type system enforces this: passing a schema without `_v` to `defineTable()` is a compile error.
131131

132132
```typescript
133-
.version(type({ id: 'string', title: 'string', _v: '1' }))
134-
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
133+
const posts = defineTable(
134+
type({ id: 'string', title: 'string', _v: '1' }),
135+
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
136+
)
135137
```
136138

137139
This makes migrations a clean switch statement:

docs/articles/why-explicit-version-discriminants.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ We started with three ways to version table schemas. All worked. All had trade-o
99
**Field presence** was the simplest. Check whether a field exists:
1010

1111
```typescript
12-
const posts = defineTable()
13-
.version(type({ id: 'string', title: 'string' }))
14-
.version(type({ id: 'string', title: 'string', views: 'number' }))
12+
const posts = defineTable(
13+
type({ id: 'string', title: 'string' }),
14+
type({ id: 'string', title: 'string', views: 'number' }),
15+
)
1516
.migrate((row) => {
1617
if (!('views' in row)) return { ...row, views: 0 };
1718
return row;
@@ -23,9 +24,10 @@ This works for two versions. It breaks down at three: what if you add a field in
2324
**Asymmetric \_v** was the recommended default. Start without `_v`, add it when you need a second version:
2425

2526
```typescript
26-
const posts = defineTable()
27-
.version(type({ id: 'string', title: 'string' }))
28-
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
27+
const posts = defineTable(
28+
type({ id: 'string', title: 'string' }),
29+
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
30+
)
2931
.migrate((row) => {
3032
if (!('_v' in row)) return { ...row, views: 0, _v: 2 };
3133
return row;
@@ -37,9 +39,10 @@ Less ceremony upfront, which felt like a win. But it introduced a trap: v1 data
3739
**Symmetric \_v** was the clean option. Include `_v` from the start:
3840

3941
```typescript
40-
const posts = defineTable()
41-
.version(type({ id: 'string', title: 'string', _v: '1' }))
42-
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
42+
const posts = defineTable(
43+
type({ id: 'string', title: 'string', _v: '1' }),
44+
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
45+
)
4346
.migrate((row) => {
4447
switch (row._v) {
4548
case 1:
@@ -83,7 +86,7 @@ It creates asymmetry. Tables would get auto-injected `_v`, but KV stores wouldn'
8386

8487
We dropped three approaches down to one: symmetric `_v` from v1. If your table has versions, every version includes `_v`. Every write includes `_v`. Every migration uses `switch (row._v)`.
8588

86-
The "start simple" argument for asymmetric `_v` lost out to a simpler observation: most tables never need versioning at all. The shorthand `defineTable(schema)` handles single-version tables with zero ceremony. The moment you need two versions, you're already opting into the builder pattern with `.version().migrate()`. Adding `_v` at that point is one field per schema, not a burden.
89+
The "start simple" argument for asymmetric `_v` lost out to a simpler observation: most tables never need versioning at all. The shorthand `defineTable(schema)` handles single-version tables with zero ceremony. The moment you need two versions, you're already opting into the variadic pattern with `defineTable(v1, v2).migrate()`. Adding `_v` at that point is one field per schema, not a burden.
8790

8891
## The DX Reframe
8992

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
export {
2-
type MarkdownMaterializerConfig,
3-
markdownMaterializer,
2+
createMaterializer,
3+
markdown,
4+
type SerializeResult,
45
toMarkdown,
56
} from './markdown.js';
67
export { parseMarkdownFile } from './parse-markdown-file.js';
78
export { prepareMarkdownFiles } from './prepare-markdown-files.js';
89
export {
9-
bodyFieldSerializer,
10-
defaultSerializer,
11-
type MarkdownSerializer,
12-
titleFilenameSerializer,
10+
bodyField,
11+
slugFilename,
12+
toIdFilename,
13+
toSlugFilename,
1314
} from './serializers.js';

0 commit comments

Comments
 (0)