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
52 changes: 52 additions & 0 deletions docs/superpowers/specs/2026-04-03-dot-path-story-api-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Dot-path notation for Story.set / Story.get

**Issue:** rohal12/spindle#150
**Date:** 2026-04-03

## Problem

`Story.set('alma.view', 'home')` creates a flat variable named `"alma.view"` instead of setting the `view` property on the `$alma` object. This is inconsistent with the twee macro `{set $alma.view = "home"}`, which works correctly via the expression engine's JS property access on Immer drafts.

`Story.get('alma.view')` has the same inconsistency — it looks up a flat key instead of traversing nested properties.

## Solution

Add dot-path resolution in `story-api.ts` only. The store layer stays unchanged (flat keys). Two private helpers:

- `getByPath(obj, path)` — split on `.`, traverse, return value or `undefined`
- `setByPath(obj, path, value)` — split on `.`, traverse Immer draft to parent, set final key

### Story.get(name)

1. Strip `%` prefix → select `transient` or `variables`
2. If `name` contains `.`: split into segments, use `getByPath`
3. Otherwise: existing flat lookup (no behavior change)

### Story.set(name, value)

1. Strip `%` prefix → select namespace
2. If `name` contains `.`: call `useStoryStore.setState()` with `setByPath` on the appropriate draft object (`state.variables` or `state.transient`)
3. Otherwise: existing `state.setVariable()` / `state.setTransient()` (no behavior change)

### Story.set(vars) — batch form

Iterate entries. Each key follows the same single-key logic above.

## Edge cases

- **Single segment (no dot):** Unchanged behavior, no regression.
- **get on missing intermediate:** Returns `undefined` (consistent with JS `obj.missing?.prop`).
- **set on missing/non-object intermediate:** Throws TypeError (consistent with `{set $undefined.prop = x}` in twee).
- **Numeric segments:** Work naturally for arrays — `items.0` accesses `items[0]` since JS objects coerce string keys.
- **Transient variables:** `Story.set('%alma.view', 'home')` strips `%`, resolves dot-path on `state.transient`.

## Files changed

- `src/story-api.ts` — add `getByPath`, `setByPath`, update `get()` and `set()`
- `test/unit/story-api.test.ts` — new tests for dot-path get/set

## Not changed

- `src/store.ts` — stays flat-key only
- `src/expression.ts` — already handles paths via JS property access
- Macros — unaffected
74 changes: 59 additions & 15 deletions src/story-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,37 @@ function ensureVariableChangedSubscription(): void {
});
}

/** Traverse a dot-delimited path on an object and return the value. */
function getByPath(obj: Record<string, unknown>, path: string): unknown {
const segments = path.split('.');
let current: unknown = obj[segments[0]!];
for (let i = 1; i < segments.length; i++) {
if (current == null) return undefined;
current = (current as Record<string, unknown>)[segments[i]!];
}
return current;
}

/** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
function setByPath(
obj: Record<string, unknown>,
path: string,
value: unknown,
): void {
const segments = path.split('.');
let current: Record<string, unknown> = obj;
for (let i = 0; i < segments.length - 1; i++) {
const next = current[segments[i]!];
if (next == null || typeof next !== 'object') {
throw new TypeError(
`spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join('.')}")`,
);
}
current = next as Record<string, unknown>;
}
current[segments[segments.length - 1]!] = value;
}

export interface StoryAPI {
get(name: string): unknown;
set(name: string, value: unknown): void;
Expand Down Expand Up @@ -192,30 +223,43 @@ export interface StoryAPI {
};
}

/** Set a single variable, resolving dot-paths if present. */
function setOne(name: string, value: unknown): void {
const isTransient = name.startsWith('%');
const key = isTransient ? name.slice(1) : name;
const namespace = isTransient ? 'transient' : 'variables';

if (key.includes('.')) {
useStoryStore.setState((state) => {
setByPath(state[namespace] as Record<string, unknown>, key, value);
});
} else {
const state = useStoryStore.getState();
if (isTransient) {
state.setTransient(key, value);
} else {
state.setVariable(key, value);
}
}
}

function createStoryAPI(): StoryAPI {
return {
get(name: string): unknown {
if (name.startsWith('%')) {
return useStoryStore.getState().transient[name.slice(1)];
}
return useStoryStore.getState().variables[name];
const isTransient = name.startsWith('%');
const key = isTransient ? name.slice(1) : name;
const store = isTransient
? useStoryStore.getState().transient
: useStoryStore.getState().variables;
return key.includes('.') ? getByPath(store, key) : store[key];
},

set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
const state = useStoryStore.getState();
if (typeof nameOrVars === 'string') {
if (nameOrVars.startsWith('%')) {
state.setTransient(nameOrVars.slice(1), value);
} else {
state.setVariable(nameOrVars, value);
}
setOne(nameOrVars, value);
} else {
for (const [k, v] of Object.entries(nameOrVars)) {
if (k.startsWith('%')) {
state.setTransient(k.slice(1), v);
} else {
state.setVariable(k, v);
}
setOne(k, v);
}
}
},
Expand Down
104 changes: 104 additions & 0 deletions test/unit/story-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,110 @@ describe('StoryAPI', () => {
});
});

describe('dot-path get/set', () => {
it('Story.get resolves dot-path on nested object', () => {
useStoryStore
.getState()
.setVariable('alma', { view: 'home', expanded: '' });
expect(Story.get('alma.view')).toBe('home');
});

it('Story.get returns undefined for missing intermediate', () => {
expect(Story.get('nonexistent.prop')).toBeUndefined();
});

it('Story.get returns undefined for missing leaf', () => {
useStoryStore.getState().setVariable('alma', { view: 'home' });
expect(Story.get('alma.missing')).toBeUndefined();
});

it('Story.get with deep path', () => {
useStoryStore
.getState()
.setVariable('config', { ui: { theme: { color: 'red' } } });
expect(Story.get('config.ui.theme.color')).toBe('red');
});

it('Story.get without dot still works (no regression)', () => {
useStoryStore.getState().setVariable('gold', 100);
expect(Story.get('gold')).toBe(100);
});

it('Story.get resolves dot-path on transient (%)', () => {
useStoryStore.getState().setTransient('ui', { panel: 'open' });
expect(Story.get('%ui.panel')).toBe('open');
});

it('Story.get with numeric segment accesses array index', () => {
useStoryStore.getState().setVariable('items', ['a', 'b', 'c']);
expect(Story.get('items.1')).toBe('b');
});

it('Story.set with dot-path sets nested property', () => {
useStoryStore
.getState()
.setVariable('alma', { view: 'home', expanded: '' });
Story.set('alma.view', 'settings');
expect(useStoryStore.getState().variables.alma).toEqual({
view: 'settings',
expanded: '',
});
});

it('Story.set with deep dot-path', () => {
useStoryStore
.getState()
.setVariable('config', { ui: { theme: { color: 'red' } } });
Story.set('config.ui.theme.color', 'blue');
expect(
(useStoryStore.getState().variables.config as any).ui.theme.color,
).toBe('blue');
});

it('Story.set without dot still works (no regression)', () => {
Story.set('gold', 100);
expect(useStoryStore.getState().variables.gold).toBe(100);
});

it('Story.set with dot-path on transient (%)', () => {
useStoryStore.getState().setTransient('ui', { panel: 'closed' });
Story.set('%ui.panel', 'open');
expect(useStoryStore.getState().transient.ui).toEqual({ panel: 'open' });
});

it('Story.set batch form with dot-paths', () => {
useStoryStore.getState().setVariable('alma', { view: 'home' });
useStoryStore.getState().setVariable('character', { level: 1 });
Story.set({ 'alma.view': 'settings', 'character.level': 5 });
expect((useStoryStore.getState().variables.alma as any).view).toBe(
'settings',
);
expect((useStoryStore.getState().variables.character as any).level).toBe(
5,
);
});

it('Story.set batch form with mixed dot-path and flat keys', () => {
useStoryStore.getState().setVariable('alma', { view: 'home' });
Story.set({ 'alma.view': 'settings', gold: 200 });
expect((useStoryStore.getState().variables.alma as any).view).toBe(
'settings',
);
expect(useStoryStore.getState().variables.gold).toBe(200);
});

it('Story.set batch form with transient dot-path', () => {
useStoryStore.getState().setTransient('ui', { panel: 'closed' });
Story.set({ '%ui.panel': 'open', gold: 50 });
expect((useStoryStore.getState().transient.ui as any).panel).toBe('open');
expect(useStoryStore.getState().variables.gold).toBe(50);
});

it('Story.set throws on missing intermediate', () => {
expect(() => Story.set('nonexistent.prop', 'value')).toThrow();
});
});

describe('navigation', () => {
it('goto navigates to passage', () => {
useStoryStore.getState().navigate('Room');
Expand Down
Loading