Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
578b833
feat(cli,server): derived entity backing through apply + migration + …
buremba May 29, 2026
5032d04
feat(server): infer measure/dimension roles from derived entity SQL
buremba May 30, 2026
912e98d
fix(server): strip derived-only measure annotations on revert to stored
buremba May 30, 2026
97da4fa
fix(cli): keep derived-entity apply idempotent (no churn)
buremba May 30, 2026
b0d8222
fix(entities): distinguish inferred vs declared measures so derived a…
buremba May 30, 2026
a7fb1b4
feat(entities): read derived entities via query_sql (no new tool)
buremba May 30, 2026
86dea8b
refactor(server): drop node-sql-parser, parse SQL with @polyglot-sql/sdk
buremba May 30, 2026
129fdc8
fix(server): reject schema-qualified table refs in joins/subqueries (…
buremba May 30, 2026
b5ee810
fix(entities): address audit findings (grain churn, empty sql, stale …
buremba May 30, 2026
c5d41be
chore(web): bump owletto pointer for derived-entity UI
buremba May 30, 2026
0a6d016
fix(server): reject creating stored rows on a derived entity type
buremba May 30, 2026
93fa83e
Merge remote-tracking branch 'origin/main' into feat/metric-derived-e…
claude May 30, 2026
2c2cefd
feat(server): query_sql/metric_series read-tier; gate auth tables for…
buremba May 31, 2026
3abd673
chore(web): re-bump owletto pointer to ba9daf0 (derived-entity UI)
buremba May 31, 2026
99fba03
refactor(entities): lean derived-entity model — stop persisting infer…
buremba May 31, 2026
6721e58
fix(server): close two org-scoping bypasses in validateAndScopeQuery
buremba May 31, 2026
7356dd4
fix(entities): enforce no-stored-rows-on-derived-types invariant airt…
buremba May 31, 2026
8a8e299
docs(server): query_sql sql-arg description allows inner ORDER BY/win…
buremba May 31, 2026
ae39a9d
fix(server): close member query_sql org-scoping bypasses (TABLE short…
buremba May 31, 2026
ba9d993
fix(server): address review — metric_series column allowlist + org_sl…
buremba May 31, 2026
d69c134
feat(server): DB-level restricted-role backstop for member query_sql/…
buremba May 31, 2026
6447a74
fix(server): measure inference sees through casts; restricted-role ch…
buremba May 31, 2026
8f9a4dd
refactor(server): drop DB restricted-role backstop; keep org-scoping …
buremba May 31, 2026
84c0732
fix(server): ORDER BY/GROUP BY output aliases + leading-comment WITH …
buremba May 31, 2026
233ef5f
refactor(server,cli): drop redundant getTableNames from scoping; CLI …
buremba May 31, 2026
2426d10
fix(server): metric_series read-only txn (block DML CTEs); conversion…
buremba May 31, 2026
6151f6b
docs(server): query_sql tool description — inner ORDER BY/LIMIT/windo…
buremba May 31, 2026
fffd379
fix(server): replace ReDoS-prone comment-stripping regex with a linea…
buremba May 31, 2026
2ef166b
chore: bump owletto pointer to f41468b5 (derived-entity form authorin…
buremba May 31, 2026
ce4d68e
chore: bump owletto pointer to 62b3ca95 (derived-entity UI merged to …
buremba May 31, 2026
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
8 changes: 0 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions db/migrations/20260529130000_entity_types_backing.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- migrate:up

-- Derived (SQL-view-backed) entity types. Today every entity type is "stored"
-- (rows are inserted/validated against metadata_schema). A "derived" entity type
-- is instead a read-only SQL view over other relations (events, other entities);
-- it has no stored rows — its data comes from running backing_sql via query_sql.
--
-- Decision B: a typed first-class column (not a metadata jsonb blob) so apply can
-- diff it and the read path can read it without parsing. There is NO separate mode
-- column — a type is derived iff backing_sql IS NOT NULL. Measure/dimension roles
-- are classified ON READ from backing_sql, not persisted.
--
-- Idempotent: no-op on databases that already have the column.
ALTER TABLE public.entity_types ADD COLUMN IF NOT EXISTS backing_sql text;

-- migrate:down

ALTER TABLE public.entity_types DROP COLUMN IF EXISTS backing_sql;
62 changes: 62 additions & 0 deletions db/migrations/20260531120000_reject_rows_on_derived_types.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- migrate:up

-- Invariant backstop: a DERIVED entity type (backing_sql IS NOT NULL) is a SQL
-- view and must NEVER have stored rows in `entities`. The app guards this in the
-- known paths (createEntity, entity-link-upsert, manage_entity_schema convert)
-- for friendly errors, but these triggers make the invariant airtight regardless
-- of which code path (or future one) writes the data.

-- (1) No stored row may point at a derived type — on INSERT, and on an UPDATE
-- that re-points an existing row's entity_type_id. Fires only when the target
-- type is derived; normal stored-type writes pass with a single PK lookup.
CREATE OR REPLACE FUNCTION public.reject_rows_on_derived_entity_type()
RETURNS trigger AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM public.entity_types et
WHERE et.id = NEW.entity_type_id AND et.backing_sql IS NOT NULL
) THEN
RAISE EXCEPTION
'entity type % is derived (a SQL view) and cannot have stored rows',
NEW.entity_type_id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_reject_rows_on_derived ON public.entities;
CREATE TRIGGER trg_reject_rows_on_derived
BEFORE INSERT OR UPDATE OF entity_type_id ON public.entities
FOR EACH ROW EXECUTE FUNCTION public.reject_rows_on_derived_entity_type();

-- (2) A type may not BECOME derived while stored rows still exist — that would
-- orphan them (the view ignores stored rows). Fires only when backing_sql is set
-- in the UPDATE; clearing it (derived → stored) is always allowed.
CREATE OR REPLACE FUNCTION public.reject_derived_conversion_with_rows()
RETURNS trigger AS $$
BEGIN
IF NEW.backing_sql IS NOT NULL AND EXISTS (
SELECT 1 FROM public.entities e
WHERE e.entity_type_id = NEW.id AND e.deleted_at IS NULL
) THEN
RAISE EXCEPTION
'entity type % cannot become a derived view while stored rows exist; delete them first',
NEW.id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_reject_derived_conversion_with_rows ON public.entity_types;
CREATE TRIGGER trg_reject_derived_conversion_with_rows
BEFORE UPDATE OF backing_sql ON public.entity_types
FOR EACH ROW EXECUTE FUNCTION public.reject_derived_conversion_with_rows();

-- migrate:down

DROP TRIGGER IF EXISTS trg_reject_derived_conversion_with_rows ON public.entity_types;
DROP FUNCTION IF EXISTS public.reject_derived_conversion_with_rows();
DROP TRIGGER IF EXISTS trg_reject_rows_on_derived ON public.entities;
DROP FUNCTION IF EXISTS public.reject_rows_on_derived_entity_type();
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@
"kysely-postgres-js": "^2.0.0",
"marked": "^17.0.4",
"marked-terminal": "^7.3.0",
"node-sql-parser": "^5.4.0",
"open": "^10.1.0",
"ora": "^8.0.1",
"pino": "^10.1.0",
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/src/commands/_lib/apply/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,72 @@ describe("ApplyClient — prune", () => {
watcher_ids: ["42"],
});
});

test("upsertEntityType POSTs a nested backing for a derived type", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const client = new ApplyClient(
{ apiBaseUrl: "https://example.test", orgSlug: "acme", token: "tok" },
(async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ success: true }), { status: 200 });
}) as typeof fetch
);

await client.upsertEntityType({
slug: "subscription",
backing: {
sql: "SELECT company_id, SUM(amount) AS spend FROM events GROUP BY company_id",
},
});

expect(calls[0]?.url).toBe(
"https://example.test/api/acme/manage_entity_schema"
);
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body.action).toBe("create");
expect(body.backing).toEqual({
sql: "SELECT company_id, SUM(amount) AS spend FROM events GROUP BY company_id",
});
});

test("listEntityTypes hoists backing_sql to a { sql } backing (derived type)", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const client = new ApplyClient(
{ apiBaseUrl: "https://example.test", orgSlug: "acme", token: "tok" },
(async (url, init) => {
calls.push({ url: String(url), init });
return new Response(
JSON.stringify({
entity_types: [
{
slug: "subscription",
metadata_schema: { type: "object", properties: {} },
backing_sql: "SELECT 1 AS x",
},
],
}),
{ status: 200 }
);
}) as typeof fetch
);

const types = await client.listEntityTypes();
expect(types[0]?.backing).toEqual({ sql: "SELECT 1 AS x" });
});

test("upsertEntityType POSTs backing:null for a stored type", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const client = new ApplyClient(
{ apiBaseUrl: "https://example.test", orgSlug: "acme", token: "tok" },
(async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ success: true }), { status: 200 });
}) as typeof fetch
);

await client.upsertEntityType({ slug: "company", name: "Company" });

const body = JSON.parse(String(calls[0]?.init?.body));
expect(body.backing).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,63 @@ describe("computeDiff — idempotency (applying twice is a no-op)", () => {
expect(secondPlan.counts.update).toBe(0);
});

test("derived entity type: same backing_sql is a noop (no persisted inference)", () => {
// A derived type stores only backing.sql — measure roles are classified on
// read, never persisted — so a re-apply is a plain, churn-free noop.
const sql =
"SELECT company_id, SUM(amount) AS spend FROM events GROUP BY company_id";
const desired = buildState([], {
memorySchema: {
entityTypes: [
{ slug: "subscription", name: "Subscription", backing: { sql } },
],
relationshipTypes: [],
},
});

const afterFirstApply: RemoteSnapshot = {
...emptyRemote(),
entityTypes: [
{ slug: "subscription", name: "Subscription", backing: { sql } },
],
};

const plan = computeDiff(desired, afterFirstApply);
const row = plan.rows.find((r) => r.kind === "entity-type");
expect(row?.verb).toBe("noop");
expect(plan.counts.update).toBe(0);
});

test("derived entity type: a changed backing_sql is an update", () => {
const desired = buildState([], {
memorySchema: {
entityTypes: [
{
slug: "subscription",
name: "Subscription",
backing: { sql: "SELECT 2 AS x FROM events" },
},
],
relationshipTypes: [],
},
});
const remote: RemoteSnapshot = {
...emptyRemote(),
entityTypes: [
{
slug: "subscription",
name: "Subscription",
backing: { sql: "SELECT 1 AS x FROM events" },
},
],
};
const plan = computeDiff(desired, remote);
const row = plan.rows.find((r) => r.kind === "entity-type");
expect(row?.verb).toBe("update");
if (row?.kind === "entity-type")
expect(row.changedFields).toContain("backing");
});

test("relationship type: same desired+remote is noop", () => {
const desired = buildState([], {
memorySchema: {
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/_lib/apply/__tests__/map-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,39 @@ describe("mapProjectToDesiredState", () => {
]);
});

test("maps a derived entity's backing ({ sql }); stored entities carry none", () => {
const subscription = defineEntityType({
key: "subscription",
name: "Subscription",
backing: {
sql: "SELECT company_id, SUM(amount) AS spend FROM revolut GROUP BY company_id",
},
});
const company = defineEntityType({ key: "company", name: "Company" });
const state = mapProjectToDesiredState(
defineConfig({ agents: [], entities: [subscription, company] })
);
const byKey = Object.fromEntries(
state.memorySchema.entityTypes.map((e) => [e.slug, e])
);
expect(byKey.subscription?.backing).toEqual({
sql: "SELECT company_id, SUM(amount) AS spend FROM revolut GROUP BY company_id",
});
// stored (default) entities never carry backing — keeps the diff churn-free
expect(byKey.company?.backing).toBeUndefined();
});

test("rejects an empty backing.sql at load time (before any remote mutation)", () => {
const bad = defineEntityType({
key: "bad",
name: "Bad",
backing: { sql: " " },
});
expect(() =>
mapProjectToDesiredState(defineConfig({ agents: [], entities: [bad] }))
).toThrow(/empty backing\.sql/i);
});

test("carries prune into DesiredState (defaults false when unset)", () => {
expect(mapProjectToDesiredState(defineConfig({ agents: [] })).prune).toBe(
false
Expand Down
Loading
Loading