Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

**Fixed**

- `affects` on a keyed or structured source that takes a guess threw `INVALID_AFFECTS_TARGET` in a dev build, so the
action never reached its `await`, and did nothing in a production build, so `pending?` stayed false (#50,
S-affects-keyed).
- A keyed source that takes a guess (`source rows key id = q.rows`) merges each answer by key instead of replacing every
row, so a row's readers re-run only when its own leaves change (#51).
- The guide's action example (`guide/async.md` §3) lost an error thrown before the first `await`: the `onFlush` clear
ran after the catch. The catch now writes the error through `onFlush` as well (#52).

## 0.3.1

**New**
Expand Down
6 changes: 6 additions & 0 deletions guide/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,16 @@ export action save(next: Profile) {
profile := answer // the truth
} catch (e) {
saveError := e.message // lands in the same frame as the revert
onFlush(() => { saveError := e.message }) // and stays, if the body failed before its first await
}
}
```

Write the error both ways. A failure after the `await` lands in the action's last instant, with the revert, and the
`onFlush` run then writes the same value, which changes nothing. A failure before the first `await` (a `throw`, or a
call that throws) lands in the action's first instant, so the clear above runs after it and would erase it; the second
`onFlush` runs after the clear, in the same instant (EF-8: one step's callbacks run in call order), and puts it back.

A handler can be the action itself, with no keyword: an arrow that writes `guess`, declares `affects`, or both
writes and awaits is an action (AC-1b). Declare a named `action` when you call it from more than one place.

Expand Down
15 changes: 14 additions & 1 deletion ref/compile/rt-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown>
},
question(name: string, compute: (prev: unknown) => unknown, o: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] {
const fn = named(name, compute);
if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); return ro(() => store); }
if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); return ro(() => store); }
if (mode.optimistic && mode.derived) { const [get] = createOptimistic(fn as any, o as any); return ro(get as Acc); } // AC-3 over an equation: the client's createOptimistic(fn), so the owner trees (and hydration keys) match
if (mode.optimistic) { const [truth] = createSignal(fn as any, o as any); const [get] = createOptimistic(() => (truth as Acc)()); return ro(get as Acc); }
if (mode.structured) {
Expand Down Expand Up @@ -241,6 +241,19 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown>
}
export type ServerApp = ReturnType<typeof createApp>;

/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new
* leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is
* assigned. */
function landDraft(draft: any, v: unknown, key: string | undefined) {
if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v);
const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row);
v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; });
draft.length = v.length;
}
function mergeLeaves(o: any, v: any) {
for (const k of Object.keys(o)) if (!(k in v)) delete o[k];
for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; }
}
/** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */
function assignDraft(draft: any, v: unknown) {
if (!v || typeof v !== "object") return;
Expand Down
17 changes: 15 additions & 2 deletions ref/compile/rt-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[])
question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] {
const fn = named(name, compute);
if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key
const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined);
const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined);
const get = () => store; storeAccs.add(get);
const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); };
return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) return typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v; /* a whole guess is the setter\'s return, which Solid diffs shallowly against the view as guesses */ let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }];
Expand Down Expand Up @@ -265,7 +265,7 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[])
onSettled: (f: () => void) => onSettled(f), // EF-7
onFlush: (f: () => void) => onFlush(f), // EF-8
refresh(name: string, get: Acc) { const key = lastKey.get(name); if (key) flights.delete(key); for (const h of hoistedOf.get(get) ?? []) refresh(h as any); const t = truthOf.get(get) ?? get; refresh((storeAccs.has(t) ? t() : t) as any); },
affects: (get: Acc) => affects(get as any),
affects: (get: Acc) => affects((storeAccs.has(get) ? get() : get) as any), // a structured source's accessor wraps its store: Solid takes the store (#50), as `refresh` above
preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v,

// ---- modules (09 MO-1): a dependency's factory runs once per app; its exports are shared by every importer ----
Expand Down Expand Up @@ -316,6 +316,19 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[])
}
export type App = ReturnType<typeof createApp>;

/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new
* leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is
* assigned. */
function landDraft(draft: any, v: unknown, key: string | undefined) {
if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v);
const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row);
v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; });
draft.length = v.length;
}
function mergeLeaves(o: any, v: any) {
for (const k of Object.keys(o)) if (!(k in v)) delete o[k];
for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; }
}
/** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */
function assignDraft(draft: any, v: unknown) {
if (!v || typeof v !== "object") return;
Expand Down
17 changes: 15 additions & 2 deletions ref/compile/rt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown>
question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] {
const fn = named(name, compute);
if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key
const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined);
const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined);
const get = () => store; storeAccs.add(get);
const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); };
return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) return typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v; /* a whole guess is the setter\'s return, which Solid diffs shallowly against the view as guesses */ let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }];
Expand Down Expand Up @@ -273,7 +273,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown>
onSettled: (f: () => void) => onSettled(f), // EF-7
onFlush: (f: () => void) => onFlush(f), // EF-8
refresh(name: string, get: Acc) { const key = lastKey.get(name); if (key) flights.delete(key); for (const h of hoistedOf.get(get) ?? []) refresh(h as any); const t = truthOf.get(get) ?? get; refresh((storeAccs.has(t) ? t() : t) as any); },
affects: (get: Acc) => affects(get as any),
affects: (get: Acc) => affects((storeAccs.has(get) ? get() : get) as any), // a structured source's accessor wraps its store: Solid takes the store (#50), as `refresh` above
preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v,

// ---- modules (09 MO-1): a dependency's factory runs once per app; its exports are shared by every importer ----
Expand Down Expand Up @@ -324,6 +324,19 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown>
}
export type App = ReturnType<typeof createApp>;

/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new
* leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is
* assigned. */
function landDraft(draft: any, v: unknown, key: string | undefined) {
if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v);
const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row);
v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; });
draft.length = v.length;
}
function mergeLeaves(o: any, v: any) {
for (const k of Object.keys(o)) if (!(k in v)) delete o[k];
for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; }
}
/** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */
function assignDraft(draft: any, v: unknown) {
if (!v || typeof v !== "object") return;
Expand Down
19 changes: 16 additions & 3 deletions ref/oracle/lower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ function boundaryRead(f: () => unknown, match: (err: unknown) => boolean, alt: (
/** A boundary's fallback that is decided when read, so a failure that changes class while the fallback shows is re-decided. */
class Fallback { get: () => unknown; constructor(g: () => unknown) { this.get = g; } }
function unwrapFallback(v0: unknown): unknown { let v = v0; if (v instanceof Fallback) v = v.get(); if (v instanceof Forward) throw v.err; return v; }
/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new
* leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is
* assigned. */
function landDraft(draft: any, v: unknown, key: string | undefined) {
if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v);
const had = new Map<unknown, any>(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row);
v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; });
draft.length = v.length;
}
function mergeLeaves(o: any, v: any) {
for (const k of Object.keys(o)) if (!(k in v)) delete o[k];
for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; }
}
/** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */
function assignDraft(draft: any, v: unknown) {
if (!v || typeof v !== "object") return;
Expand Down Expand Up @@ -257,7 +270,7 @@ export class Oracle {
this.hoistAwaits(d.name, d.init, scope);
const compute = this.computeOf(d.name, d.init, scope);
if (this.optimisticTargets.has(d.name) && this.pathTargets.has(d.name)) { // AC-3 path targets over ST-5 structured: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key
const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; assignDraft(draft, v); };
const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; landDraft(draft, v, d.key); };
const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined);
const plain = () => { throw new Unsupported("a plain write to an optimistic store"); };
scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore });
Expand Down Expand Up @@ -294,7 +307,7 @@ export class Oracle {
const compute = this.computeOf(d.name, d.e, scope);
const plain = () => { throw new Unsupported("a plain write to an equation"); };
if (this.pathTargets.has(d.name) || d.key) {
const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; assignDraft(draft, v); };
const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; landDraft(draft, v, d.key); };
const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined);
scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore });
} else {
Expand Down Expand Up @@ -508,7 +521,7 @@ export class Oracle {
case "throw": throw untrack(() => this.ev(s.e, scope, env));
case "action": this.lowerAction([], s.body, scope, env)(); return;
case "refresh": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_REFRESH_TARGET"); const key = this.lastKey.get(s.name); if (key) this.forget(key); const d = Scope.all.find(x => x.vars.get(s.name) === b); for (const h of (d && this.hoistedByName.get(d)?.get(s.name)) ?? []) refresh(h as any); refresh((b.k === "store" ? b.get() : ((b.k === "sig" || b.k === "memo") && (b as any).truth) || b.get) as any); return; }
case "affects": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_AFFECTS_TARGET"); affects(b.get as any); return; }
case "affects": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_AFFECTS_TARGET"); affects((b.k === "store" ? b.get() : b.get) as any); return; } // a store binding's get wraps the store: Solid takes the store (#50)
case "optimistic": case "write": {
const b = s.target.ctx ? this.provision(s.target.name, scope) : scope.lookup(s.target.name);
const v = untrack(() => this.ev(s.e, scope, env)); // W-6: a function value is Solid's updater form — the setters take it as is
Expand Down
46 changes: 46 additions & 0 deletions scenarios/S-affects-keyed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# S-affects-keyed — `affects` on a keyed source that takes a guess

Rules exercised: AC-5, AC-3, AC-4, H-9.

## Program

```rx
export default component App() {
source tick := 0
day = await fetch("d" + str(tick))
source rows key id = day.rows
source err := "none"
action move(id: number, at: number) {
try {
let i = rows.findIndex(r => r.id == id)
guess rows[i].at := at
affects rows
await fetch("save")
refresh day
} catch (e) { err := e.message }
}
errored fallback(e) { <p>error</p> } {
loading fallback { <p>loading</p> } {
<p>{for r in rows { <i>{r.at}</i> }}{pending? rows ? "saving" : ""} {err}</p>
}
}
}
```

A keyed source over an answer, written by a guess, lowers to an optimistic store. The action declares its work on the
whole list with `affects rows`, so `pending? rows` is true until the action ends (AC-5), and the save is asked.

## Frames

| instant | action | frame |
| ------- | ------ | ----- |
| 0 | mount | `loading` |
| 1 | resolve "d0" {"rows":[{"id":1,"at":0},{"id":2,"at":3}]} | `03 none` |
| 2 | handler `move(1, 5)` | `53saving none` |
| 3 | resolve "save" 0 | `53saving none` |
| 4 | resolve "d0" {"rows":[{"id":1,"at":5},{"id":2,"at":3}]} | `53 none` |

Reported as solidjs/reactivescript#50: the compiled runtime handed `affects` the source's accessor, a wrapper around
the store, where `refresh` already unwrapped it. Solid's dev build threw `INVALID_AFFECTS_TARGET` into the action's
`catch`, so the save was never asked and the guess reverted (instant 2 read `03 none`, then the error); its
production build ignored the call, so `pending? rows` never turned true.
Loading