Skip to content
Draft
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
114 changes: 114 additions & 0 deletions integrationTests/resources/computed-and-cyclic-serialization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* harper#1484 — computed-scalar default surfacing (Fix A) + cyclic-enumerable serialization guard (Fix B).
*
* Fix A: REST GET with no explicit select must include @computed *scalar* attributes (their getters are
* non-enumerable, so JSON.stringify used to silently drop them). Table-typed computeds/relationships
* stay lazy by default.
* Fix B: @enumerable relationships that form a cycle (Author<->Book, and the Category self-loop) must
* serialize as { <primaryKey> } reference stubs rather than overflowing the stack / 500ing.
*
* Reproduction:
* npm run test:integration -- "integrationTests/resources/computed-and-cyclic-serialization.test.ts"
*/
import { suite, test, before, after } from 'node:test';
import { ok, strictEqual, deepStrictEqual } from 'node:assert';
import { resolve } from 'node:path';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine
import { createApiClient } from '../apiTests/utils/client.mjs';

const FIXTURE_PATH = resolve(import.meta.dirname, 'computed-and-cyclic-serialization');
const skipSuite = process.platform === 'win32';
const ENGINE = process.env.HARPER_STORAGE_ENGINE || 'rocksdb(default)';

suite(
`harper#1484 computed + cyclic serialization [engine=${ENGINE}]`,
{ skip: skipSuite },
(ctx: ContextWithHarper) => {
let httpURL: string;
let auth: string;
let client: ReturnType<typeof createApiClient>;

async function rest(method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> {
const r = await fetch(`${httpURL}${path}`, {
method,
headers: { 'Content-Type': 'application/json', 'Authorization': auth },
body: body == null ? undefined : JSON.stringify(body),
});
let parsed: any = null;
try {
parsed = await r.json();
} catch {
/* ignore */
}
return { status: r.status, body: parsed };
}

before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH, { config: {}, env: {} });
client = createApiClient(ctx.harper);
httpURL = ctx.harper.httpURL;
auth = client.headers.Authorization;

await rest('PUT', '/Author/1', { id: '1', name: 'Ada' });
await rest('PUT', '/Book/10', { id: '10', authorId: '1', title: 'Analytical Engine' });
// Category self-loop: 1 -> 2 -> 1
await rest('PUT', '/Category/1', { id: '1', name: 'root', parentId: '2' });
await rest('PUT', '/Category/2', { id: '2', name: 'child', parentId: '1' });
// Node link cycle via an unconstrained (`Any`) @computed resolver: A -> B -> A
await rest('PUT', '/Node/A', { id: 'A', linkId: 'B' });
await rest('PUT', '/Node/B', { id: 'B', linkId: 'A' });
// Ref link cycle via a *typed-scalar* (`String`) @computed resolver that returns a live entity: X -> Y -> X
await rest('PUT', '/Ref/X', { id: 'X', linkId: 'Y' });
await rest('PUT', '/Ref/Y', { id: 'Y', linkId: 'X' });
});

after(async () => {
await teardownHarper(ctx);
});

// ---- Fix A: computed scalar on the default read path ----

test('A1: default GET includes @computed scalar (displayName)', async () => {
const { status, body } = await rest('GET', '/Author/1');
strictEqual(status, 200);
strictEqual(body?.name, 'Ada');
strictEqual(body?.displayName, 'Ada', 'computed scalar must be surfaced without an explicit select');
});

// ---- Fix B: cyclic @enumerable serialization does not overflow ----

test('B1: mutual enumerable cycle (Author<->Book) serializes with a reference stub', async () => {
const { status, body } = await rest('GET', '/Author/1');
strictEqual(status, 200, 'must not 500/overflow on a cyclic-enumerable read');
// Author.books is enumerable -> each Book.author is enumerable -> back to this Author = cycle.
const book = Array.isArray(body?.books) ? body.books[0] : undefined;
ok(book, 'enumerable books should be present in serialization');
deepStrictEqual(book?.author, { id: '1' }, 'cyclic back-edge collapses to a { id } reference stub');
});

test('B2: self-referential tree (Category.parent) does not overflow', async () => {
const { status, body } = await rest('GET', '/Category/1');
strictEqual(status, 200, 'self-loop must not overflow');
strictEqual(body?.parent?.id, '2');
deepStrictEqual(body?.parent?.parent, { id: '1' }, 'tree cycle collapses to a reference stub');
});

test('B3: unconstrained @computed returning a live entity cycle does not overflow', async () => {
const { status, body } = await rest('GET', '/Node/A');
strictEqual(status, 200, 'untyped-computed runtime cycle must not overflow (guarded, not fast path)');
strictEqual(body?.linked?.id, 'B', 'the computed live entity is surfaced');
deepStrictEqual(body?.linked?.linked, { id: 'A' }, 'runtime cycle collapses to a reference stub');
});

test('B4: typed-scalar @computed returning a live entity cycle does not overflow (guarded path)', async () => {
// Regression for the review follow-up: a `String`-typed @computed whose resolver returns a live
// struct took the raw fast path before the fix (only `Any`/untyped computeds were guarded) and
// overflowed. It must now route through the guarded path regardless of the declared type.
const { status, body } = await rest('GET', '/Ref/X');
strictEqual(status, 200, 'a typed-scalar computed returning a live struct must be guarded, not fast-path');
strictEqual(body?.linked?.id, 'Y', 'the computed live entity is surfaced');
deepStrictEqual(body?.linked?.linked, { id: 'X' }, 'runtime cycle collapses to a reference stub');
});
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// harper#1484 — resolver for the unconstrained (`Any`) @computed `Node.linked`, which returns a LIVE
// entity (another Node). The static cycle detector can't see this edge, so the table must fall to the
// guarded serialization path; two Nodes that link each other must serialize as reference stubs, not
// overflow. Exercises the "new exposure" the cross-model review flagged for computed-scalar-only tables.
// getSync returns the live decoded struct synchronously (a Promise/async get would serialize as {} and
// never cycle) — this is what makes the runtime cycle reachable during synchronous JSON serialization.
tables.Node.setComputedAttribute('linked', (record) =>
record.linkId != null ? tables.Node.primaryStore.getSync(record.linkId) : undefined
);

// Review follow-up: `Ref.linked` is declared `String` but the resolver returns a live entity (another
// Ref). The declared scalar type made this take the raw fast path before the fix; it must now be guarded
// so two Refs linking each other serialize as reference stubs rather than overflowing.
tables.Ref.setComputedAttribute('linked', (record) =>
record.linkId != null ? tables.Ref.primaryStore.getSync(record.linkId) : undefined
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# harper#1484 — default-read computed-scalar surfacing + cyclic-enumerable serialization guard.
#
# Fix A: a @computed *scalar* must appear on the default (no-select) REST read, even though its
# getter is non-enumerable. A @computed whose type is a table must stay lazy (edge guard).
# Fix B: @enumerable relationships that form a cycle must serialize as { primaryKey } reference
# stubs instead of overflowing the stack.

type Author @table @export {
id: ID @primaryKey
name: String
# Fix A: computed SCALAR — surfaced by default.
displayName: String @computed(from: "name")
# Fix B: enumerable side of a mutual cycle (Author.books -> Book.author -> Author ...).
books: [Book] @relationship(to: authorId) @enumerable
}

type Book @table @export {
id: ID @primaryKey
authorId: ID @indexed
title: String
author: Author @relationship(from: authorId) @enumerable
}

# Self-referential tree: enumerable parent pointing back at the same table (a self-loop cycle).
type Category @table @export {
id: ID @primaryKey
name: String
parentId: ID @indexed
parent: Category @relationship(from: parentId) @enumerable
}

# Unconstrained (`Any`) @computed whose resolver (resources.js) returns a live entity. The static edge
# graph can't see this, so `linked` forces the guarded serialization path; two Nodes linking each other
# must not overflow. This is the "new exposure" the cross-model review flagged for computed-scalar tables.
type Node @table @export {
id: ID @primaryKey
linkId: ID
linked: Any @computed
}

# Review follow-up (cb1kenobi): a @computed declared with a concrete SCALAR type whose resolver
# nonetheless returns a live entity. JS can't enforce the declared return type, so before the fix this
# took the raw fast path (only `Any`/untyped computeds were guarded) and overflowed on a runtime cycle.
# Any surfaced non-table @computed now routes through the guarded path.
type Ref @table @export {
id: ID @primaryKey
linkId: ID
linked: String @computed
}
Loading