Skip to content

Commit 179e746

Browse files
authored
Merge pull request #72 from coasys/feat/query-abort
feat(schema-renderer): abort in-flight findAll on cleanup and re-run
2 parents 8a372fa + 398d5eb commit 179e746

5 files changed

Lines changed: 343 additions & 12 deletions

File tree

.github/workflows/build.yaml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: Build
2+
3+
on:
4+
push:
5+
branches: [dev]
6+
pull_request:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
# WE's .nvmrc pins Node 24 (uses Node 22+ features like `fs.globSync`).
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: "24"
18+
19+
# ad4m's monorepo uses object-format pnpm.overrides which pnpm v10
20+
# rejects. Install pnpm 9.15.0 directly via npm and pin it for the
21+
# whole workflow — WE's `packageManager: pnpm@10.18.3` works fine
22+
# under pnpm 9 (the warning is cosmetic) and pnpm/action-setup@v4
23+
# refuses to override packageManager without erroring.
24+
- name: Install pnpm 9.15.0
25+
run: npm install -g pnpm@9.15.0
26+
27+
- name: Detect branch
28+
id: branch
29+
run: |
30+
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
31+
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
32+
if git ls-remote --exit-code --heads \
33+
https://github.com/coasys/ad4m.git "$BRANCH" >/dev/null 2>&1; then
34+
echo "ad4m_branch=$BRANCH" >> "$GITHUB_OUTPUT"
35+
echo "✅ Found matching AD4M branch: $BRANCH"
36+
else
37+
echo "ad4m_branch=dev" >> "$GITHUB_OUTPUT"
38+
echo "ℹ️ No matching AD4M branch — using dev"
39+
fi
40+
41+
# --- AD4M linked build (published packages have broken workspace: refs) ---
42+
43+
- name: Clone AD4M
44+
run: |
45+
git clone --depth 1 --single-branch \
46+
--branch "${{ steps.branch.outputs.ad4m_branch }}" \
47+
https://github.com/coasys/ad4m.git ad4m
48+
49+
- name: Get AD4M commit hash
50+
id: ad4m-hash
51+
run: |
52+
HASH=$(cd ad4m && git rev-parse HEAD)
53+
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
54+
echo "AD4M commit: $HASH"
55+
56+
- name: Cache AD4M built outputs
57+
id: ad4m-cache
58+
uses: actions/cache@v4
59+
with:
60+
path: |
61+
ad4m/node_modules
62+
ad4m/core/lib
63+
ad4m/connect/dist
64+
key: ad4m-sdk-${{ steps.ad4m-hash.outputs.hash }}
65+
66+
- name: Install & build AD4M packages
67+
if: steps.ad4m-cache.outputs.cache-hit != 'true'
68+
run: |
69+
cd ad4m
70+
pnpm install --no-frozen-lockfile
71+
72+
cd core && pnpm exec tsc && pnpm run bundle && cd ..
73+
cd connect && pnpm run build && cd ..
74+
75+
# --- WE build ---
76+
77+
- name: Cache WE dependencies
78+
uses: actions/cache@v4
79+
with:
80+
path: |
81+
node_modules
82+
*/node_modules
83+
!ad4m/node_modules
84+
key: we-deps-${{ hashFiles('pnpm-lock.yaml') }}
85+
86+
- name: Override AD4M packages with local builds
87+
run: |
88+
node -e "
89+
const pkg = require('./package.json');
90+
pkg.pnpm = pkg.pnpm || {};
91+
pkg.pnpm.overrides = pkg.pnpm.overrides || {};
92+
pkg.pnpm.overrides['@coasys/ad4m'] = 'file:./ad4m/core';
93+
pkg.pnpm.overrides['@coasys/ad4m-connect'] = 'file:./ad4m/connect';
94+
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n');
95+
"
96+
97+
- name: Install WE dependencies
98+
run: pnpm install --no-frozen-lockfile
99+
100+
- name: Clear build caches for linked SDK
101+
run: |
102+
rm -rf .turbo node_modules/.cache
103+
find . -name '.turbo' -type d -not -path './ad4m/*' -not -path './node_modules/*' -exec rm -rf {} + 2>/dev/null || true
104+
find . -name '.vite' -type d -path '*/node_modules/.vite' -not -path './ad4m/*' -exec rm -rf {} + 2>/dev/null || true
105+
106+
# Non-blocking: this is the first CI workflow on the WE repo, so
107+
# pre-existing lint debt on `dev` (mostly `no-explicit-any`
108+
# warnings, plus the occasional unsorted import) shows up the
109+
# moment lint actually runs. Surface the warnings to reviewers
110+
# via the run annotations, but don't fail the build until the
111+
# backlog is cleared in a follow-up.
112+
- name: Lint
113+
run: NODE_OPTIONS='--max-old-space-size=8192' pnpm lint
114+
continue-on-error: true
115+
116+
- name: Build
117+
run: NODE_OPTIONS='--max-old-space-size=8192' pnpm build
118+
119+
# Non-blocking on first CI introduction. Same rationale as Lint:
120+
# `dev` carries pre-existing failures in unrelated packages (e.g.
121+
# an app-framework test imports a file that no longer exists) and
122+
# this PR is the first to actually run vitest in CI. Surface the
123+
# failures in the run summary so a follow-up can clear the
124+
# backlog, but don't block the abort-controller work on unrelated
125+
# test debt.
126+
- name: Test
127+
run: pnpm test
128+
continue-on-error: true

eslint.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export default [
1717
'**/target/**',
1818
'**/dist-electron/**',
1919
'**/.storybook/**',
20+
// The branch-aware ad4m build (scripts/build-with-ad4m-link.sh) and
21+
// its CI counterpart clone the matching ad4m branch into ./ad4m and
22+
// link it via pnpm.overrides. ESLint must not walk into that
23+
// checkout — it's not WE source, and walking it OOMs the linter.
24+
'ad4m/**',
2025
],
2126
},
2227
{

packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,29 @@ function createQuerySignal(
242242
});
243243
onCleanup(() => builder.dispose());
244244
} else {
245-
(ModelClass.findAll(p, queryOptions) as Promise<unknown[]>).then((results) => {
246-
setItems(reconcile(normalise(results), { key: 'id', merge: true }));
247-
});
245+
// The effect re-runs when any reactive dep changes — perspective swap,
246+
// resolved params, etc. When that happens (or the component unmounts)
247+
// the previous findAll may still be in flight against a slow query.
248+
// An AbortController scoped to this iteration tells the executor to
249+
// drop the JSON reply for the stale query instead of paying its
250+
// serialise + WebSocket + deserialise cost.
251+
//
252+
// ad4m's `Ad4mModel.findAll` accepts `options?: { signal?: AbortSignal }`
253+
// as the 3rd argument and forwards it through `perspective.modelQuery`
254+
// to the executor's `request.cancel` machinery.
255+
const controller = new AbortController();
256+
onCleanup(() => controller.abort());
257+
(ModelClass.findAll(p, queryOptions, { signal: controller.signal }) as Promise<unknown[]>)
258+
.then((results) => {
259+
if (controller.signal.aborted) return;
260+
setItems(reconcile(normalise(results), { key: 'id', merge: true }));
261+
})
262+
.catch((err) => {
263+
// AbortError = a newer effect run or unmount cancelled this query.
264+
// Silently drop — no UI state to update, the new run handles it.
265+
if (err instanceof DOMException && err.name === 'AbortError') return;
266+
throw err;
267+
});
248268
}
249269
});
250270

@@ -584,7 +604,21 @@ export function RenderSchema({ node, stores, registry, context = {}, children }:
584604
builder.subscribe(handleResults).then(handleResults);
585605
onCleanup(() => builder.dispose());
586606
} else {
587-
(ModelClass.findAll(p, queryOptions) as Promise<unknown[]>).then(handleResults);
607+
// Same abort discipline as the list-path createQuerySignal: the
608+
// effect re-runs on perspective swap / param change, and any
609+
// in-flight findAll from the previous run should be cancelled so
610+
// we don't pay its serialise + transit + deserialise tax.
611+
const controller = new AbortController();
612+
onCleanup(() => controller.abort());
613+
(ModelClass.findAll(p, queryOptions, { signal: controller.signal }) as Promise<unknown[]>)
614+
.then((results) => {
615+
if (controller.signal.aborted) return;
616+
handleResults(results);
617+
})
618+
.catch((err) => {
619+
if (err instanceof DOMException && err.name === 'AbortError') return;
620+
throw err;
621+
});
588622
}
589623
});
590624
}

packages/schema-system/frameworks/solid/tests/queryToken.test.tsx

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('$query token', () => {
6060
const builder = createMockBuilder();
6161
const MockModel = { query: vi.fn(() => builder), findAll: vi.fn() };
6262
const stores = {
63-
spaceStore: { perspective: () => ({ uuid: 'test-perspective' }) },
63+
adamStore: { currentPerspective: () => ({ uuid: 'test-perspective' }) },
6464
$getModel: () => MockModel,
6565
};
6666

@@ -86,7 +86,7 @@ describe('$query token', () => {
8686
const builder = createMockBuilder();
8787
const MockModel = { query: vi.fn(() => builder), findAll: vi.fn() };
8888
const stores = {
89-
spaceStore: { perspective: () => ({ uuid: 'test-perspective' }) },
89+
adamStore: { currentPerspective: () => ({ uuid: 'test-perspective' }) },
9090
$getModel: () => MockModel,
9191
};
9292

@@ -116,7 +116,7 @@ describe('$query token', () => {
116116
const builder = createMockBuilder();
117117
const MockModel = { query: vi.fn(() => builder), findAll: vi.fn() };
118118
const stores = {
119-
spaceStore: { perspective: () => ({ uuid: 'test-perspective' }) },
119+
adamStore: { currentPerspective: () => ({ uuid: 'test-perspective' }) },
120120
$getModel: () => MockModel,
121121
};
122122

@@ -150,7 +150,7 @@ describe('$query token', () => {
150150

151151
const [perspective, setPerspective] = createSignal<unknown>({ uuid: 'perspective-1' });
152152
const stores = {
153-
spaceStore: { perspective },
153+
adamStore: { currentPerspective: perspective },
154154
$getModel: () => MockModel,
155155
};
156156

@@ -182,7 +182,7 @@ describe('$query token', () => {
182182
it('returns empty array when perspective is null', async () => {
183183
const MockModel = { query: vi.fn(), findAll: vi.fn() };
184184
const stores = {
185-
spaceStore: { perspective: () => null },
185+
adamStore: { currentPerspective: () => null },
186186
$getModel: () => MockModel,
187187
};
188188

@@ -209,7 +209,7 @@ describe('$query token', () => {
209209
findAll: vi.fn(() => Promise.resolve([{ id: 1 }])),
210210
};
211211
const stores = {
212-
spaceStore: { perspective: () => ({ uuid: 'p1' }) },
212+
adamStore: { currentPerspective: () => ({ uuid: 'p1' }) },
213213
$getModel: () => MockModel,
214214
};
215215

@@ -228,13 +228,105 @@ describe('$query token', () => {
228228
expect(JSON.parse(el?.textContent ?? '[]')).toEqual([{ id: 1 }]);
229229
});
230230

231+
// ---- AbortSignal threading + cleanup ----
232+
233+
it('passes an AbortSignal to findAll and aborts it on unmount', async () => {
234+
let capturedSignal: AbortSignal | undefined;
235+
const MockModel = {
236+
query: vi.fn(),
237+
findAll: vi.fn((_p: unknown, _q: unknown, opts: { signal?: AbortSignal } = {}) => {
238+
capturedSignal = opts.signal;
239+
return Promise.resolve([]);
240+
}),
241+
};
242+
const stores = {
243+
adamStore: { currentPerspective: () => ({ uuid: 'p1' }) },
244+
$getModel: () => MockModel,
245+
};
246+
247+
const node: SchemaNode = {
248+
type: 'DataDisplay',
249+
props: { data: { $query: { model: 'Post', subscribe: false } } },
250+
};
251+
252+
const { unmount } = render(() => <RenderSchema node={node} stores={stores} registry={registry} />);
253+
await tick();
254+
255+
expect(MockModel.findAll).toHaveBeenCalledOnce();
256+
expect(capturedSignal).toBeInstanceOf(AbortSignal);
257+
expect(capturedSignal!.aborted).toBe(false);
258+
259+
// Unmount should trigger onCleanup, which aborts the controller.
260+
unmount();
261+
expect(capturedSignal!.aborted).toBe(true);
262+
});
263+
264+
it('aborts a stale findAll when the effect re-runs', async () => {
265+
const signals: AbortSignal[] = [];
266+
const MockModel = {
267+
query: vi.fn(),
268+
findAll: vi.fn((_p: unknown, _q: unknown, opts: { signal?: AbortSignal } = {}) => {
269+
if (opts.signal) signals.push(opts.signal);
270+
return new Promise<unknown[]>(() => {
271+
// Never resolve — simulate slow query
272+
});
273+
}),
274+
};
275+
const [perspective, setPerspective] = createSignal<{ uuid: string } | null>({ uuid: 'p1' });
276+
const stores = {
277+
adamStore: { currentPerspective: perspective },
278+
$getModel: () => MockModel,
279+
};
280+
281+
const node: SchemaNode = {
282+
type: 'DataDisplay',
283+
props: { data: { $query: { model: 'Post', subscribe: false } } },
284+
};
285+
286+
render(() => <RenderSchema node={node} stores={stores} registry={registry} />);
287+
await tick();
288+
expect(signals.length).toBe(1);
289+
expect(signals[0].aborted).toBe(false);
290+
291+
// Trigger effect re-run by changing the reactive perspective dep.
292+
setPerspective({ uuid: 'p2' });
293+
await tick();
294+
295+
// Old run's controller aborted; new run got its own fresh signal.
296+
expect(signals[0].aborted).toBe(true);
297+
expect(signals.length).toBe(2);
298+
expect(signals[1].aborted).toBe(false);
299+
});
300+
301+
it('swallows AbortError from findAll without surfacing it', async () => {
302+
const MockModel = {
303+
query: vi.fn(),
304+
findAll: vi.fn(() => Promise.reject(new DOMException('Aborted', 'AbortError'))),
305+
};
306+
const stores = {
307+
adamStore: { currentPerspective: () => ({ uuid: 'p1' }) },
308+
$getModel: () => MockModel,
309+
};
310+
311+
const node: SchemaNode = {
312+
type: 'DataDisplay',
313+
props: { data: { $query: { model: 'Post', subscribe: false } } },
314+
};
315+
316+
// If the catch arm doesn't swallow AbortError, the unhandled rejection
317+
// would surface as a test failure.
318+
render(() => <RenderSchema node={node} stores={stores} registry={registry} />);
319+
await tick();
320+
expect(MockModel.findAll).toHaveBeenCalledOnce();
321+
});
322+
231323
// ---- Query params forwarding ----
232324

233325
it('forwards where/order/limit params to query builder', async () => {
234326
const builder = createMockBuilder();
235327
const MockModel = { query: vi.fn(() => builder), findAll: vi.fn() };
236328
const stores = {
237-
spaceStore: { perspective: () => ({ uuid: 'p1' }) },
329+
adamStore: { currentPerspective: () => ({ uuid: 'p1' }) },
238330
$getModel: () => MockModel,
239331
};
240332

@@ -267,7 +359,7 @@ describe('$query token', () => {
267359
it('warns and returns empty array when $getModel is missing', async () => {
268360
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
269361
const stores = {
270-
spaceStore: { perspective: () => ({ uuid: 'p1' }) },
362+
adamStore: { currentPerspective: () => ({ uuid: 'p1' }) },
271363
// Note: no $getModel
272364
};
273365

0 commit comments

Comments
 (0)