Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .agents/skills/workspace-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,22 @@ export type User = InferTableRow<typeof usersTable>;

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.

### Builder (Multiple Versions)
### Variadic (Multiple Versions)

Use when you need to evolve a schema over time:

```typescript
const posts = defineTable()
.version(type({ id: 'string', title: 'string', _v: '1' }))
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
.migrate((row) => {
switch (row._v) {
case 1:
return { ...row, views: 0, _v: 2 };
case 2:
return row;
}
});
const posts = defineTable(
type({ id: 'string', title: 'string', _v: '1' }),
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
).migrate((row) => {
switch (row._v) {
case 1:
return { ...row, views: 0, _v: 2 };
case 2:
return row;
}
});
```

## KV Stores
Expand Down
7 changes: 4 additions & 3 deletions docs/articles/20260127T120000-static-workspace-api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import {
import { type } from 'arktype';

// Define table schemas with versioning
const posts = defineTable()
.version(type({ id: 'string', title: 'string', _v: '1' }))
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
const posts = defineTable(
type({ id: 'string', title: 'string', _v: '1' }),
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
)
.migrate((row) => {
if (row._v === 1) return { ...row, views: 0, _v: 2 };
return row;
Expand Down
76 changes: 76 additions & 0 deletions docs/articles/lazy-initializer-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Stop Writing Let-Then-Check—Use a Lazy Initializer

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.

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:

```typescript
let allEntries: YKeyValueLwwEntry<T>[] | null = null;
const getAllEntries = () => {
allEntries ??= yarray.toArray();
return allEntries;
};
let entryIndexMap: Map<YKeyValueLwwEntry<T>, number> | null = null;
const getEntryIndex = (entry: YKeyValueLwwEntry<T>): number => {
if (!entryIndexMap) {
const entries = getAllEntries();
entryIndexMap = new Map();
for (let i = 0; i < entries.length; i++) {
const indexedEntry = entries[i];
if (indexedEntry) entryIndexMap.set(indexedEntry, i);
}
}
return entryIndexMap.get(entry) ?? -1;
};
```

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.

## The Abstraction That Was Missing

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:

```typescript
export function lazy<T>(init: () => T): () => T {
let value: T | undefined;
let initialized = false;
return () => {
if (!initialized) {
value = init();
initialized = true;
}
return value as T;
};
}
```

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.

## What the Code Looks Like After

```typescript
const getAllEntries = lazy(() => yarray.toArray());
const getEntryIndexMap = lazy(() => {
const entries = getAllEntries();
const map = new Map<YKeyValueLwwEntry<T>, number>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry) map.set(entry, i);
}
return map;
});
const getEntryIndex = (entry: YKeyValueLwwEntry<T>): number =>
getEntryIndexMap().get(entry) ?? -1;
```

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.

## Scope Is the Key Property

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.

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.

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."

The vague code smell from the let-then-check pattern was pointing at a real gap. `lazy()` fills it.
36 changes: 16 additions & 20 deletions docs/articles/schema-granularity-matches-write-granularity.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,17 @@ If Alice edits `title` and Bob edits `views` at the same time, one of their chan
Here's how it looks in practice:

```typescript
const posts = defineTable('posts')
.version(type({ id: 'string', title: 'string', _v: '1' }))
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
.version(
type({
id: 'string',
title: 'string',
views: 'number',
tags: 'string[]',
_v: '3',
}),
)
const posts = defineTable(
type({ id: 'string', title: 'string', _v: '1' }),
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
type({
id: 'string',
title: 'string',
views: 'number',
tags: 'string[]',
_v: '3',
}),
)
.migrate((row) => {
switch (row._v) {
case 1:
Expand All @@ -93,7 +92,7 @@ const posts = defineTable('posts')
});
```

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

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.

Expand All @@ -102,13 +101,10 @@ Because the entire row is atomic, you're guaranteed that `row._v` tells you the
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:

```typescript
const theme = defineKv('theme')
.version(type({ mode: "'light' | 'dark'" }))
.version(type({ mode: "'light' | 'dark' | 'system'", accentColor: 'string' }))
.migrate((v) => {
if (!('accentColor' in v)) return { ...v, accentColor: '#3b82f6' };
return v;
});
const theme = defineKv(
type({ mode: "'light' | 'dark' | 'system'", accentColor: 'string' }),
{ mode: 'light', accentColor: '#3b82f6' },
)
```

Each KV value is an atomic blob with a coherent shape. For small objects, field presence checks are simpler than version discriminators.
Expand Down
6 changes: 4 additions & 2 deletions docs/articles/versioned-schemas-migrate-on-read.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,10 @@ Note: ArkType automatically discriminates unions for O(1) performance. For other
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.

```typescript
.version(type({ id: 'string', title: 'string', _v: '1' }))
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
const posts = defineTable(
type({ id: 'string', title: 'string', _v: '1' }),
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
)
```

This makes migrations a clean switch statement:
Expand Down
23 changes: 13 additions & 10 deletions docs/articles/why-explicit-version-discriminants.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ We started with three ways to version table schemas. All worked. All had trade-o
**Field presence** was the simplest. Check whether a field exists:

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

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

```typescript
const posts = defineTable()
.version(type({ id: 'string', title: 'string', _v: '1' }))
.version(type({ id: 'string', title: 'string', views: 'number', _v: '2' }))
const posts = defineTable(
type({ id: 'string', title: 'string', _v: '1' }),
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
)
.migrate((row) => {
switch (row._v) {
case 1:
Expand Down Expand Up @@ -83,7 +86,7 @@ It creates asymmetry. Tables would get auto-injected `_v`, but KV stores wouldn'

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)`.

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.
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.

## The DX Reframe

Expand Down
13 changes: 7 additions & 6 deletions packages/workspace/src/extensions/materializer/markdown/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
export {
type MarkdownMaterializerConfig,
markdownMaterializer,
createMaterializer,
markdown,
type SerializeResult,
toMarkdown,
} from './markdown.js';
export { parseMarkdownFile } from './parse-markdown-file.js';
export { prepareMarkdownFiles } from './prepare-markdown-files.js';
export {
bodyFieldSerializer,
defaultSerializer,
type MarkdownSerializer,
titleFilenameSerializer,
bodyField,
slugFilename,
toIdFilename,
toSlugFilename,
} from './serializers.js';
Loading
Loading