Skip to content

Refactor database tracking and safety proxy architecture#15

Closed
digitalmio wants to merge 6 commits into
mainfrom
feat/sql-parser
Closed

Refactor database tracking and safety proxy architecture#15
digitalmio wants to merge 6 commits into
mainfrom
feat/sql-parser

Conversation

@digitalmio
Copy link
Copy Markdown
Owner

Add SQL parsing for granular table and WHERE ID tracking. Replace separate mutation/select proxies with unified builder proxy. Extract row-level tracking to support future invalidation by specific IDs.

  • New createSafetyProxy with centralized guard enforcement
  • New createBuilderProxy unifying select/insert/update/delete handling
  • parseSqlTracking for AST-based table and WHERE clause extraction
  • createTrackedRawDb to track raw SQL execution
  • buildPkMap to collect primary key info from schema
  • New tracking.ts module for cascading writes and row ID recording
  • Remove createTrackedDb, createSelectProxy, createMutationProxy, createQueryProxy, and checkResultWarnings
  • Updated README to clarify raw SQL automatic tracking

Add SQL parsing for granular table and WHERE ID tracking. Replace
separate mutation/select proxies with unified builder proxy. Extract
row-level tracking to support future invalidation by specific IDs.

- New `createSafetyProxy` with centralized guard enforcement
- New `createBuilderProxy` unifying select/insert/update/delete handling
- `parseSqlTracking` for AST-based table and WHERE clause extraction
- `createTrackedRawDb` to track raw SQL execution
- `buildPkMap` to collect primary key info from schema
- New `tracking.ts` module for cascading writes and row ID recording
- Remove `createTrackedDb`, `createSelectProxy`, `createMutationProxy`,
  `createQueryProxy`, and `checkResultWarnings`
- Updated README to clarify raw SQL automatic tracking
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR replaces the split createTrackedDb/createSelectProxy/createMutationProxy architecture with a unified createSafetyProxy + createBuilderProxy, adds AST-based SQL parsing via sqlite3-parser to extract table and WHERE-clause metadata, and introduces row-level ID tracking (rowIds, buildPkMap) for future fine-grained invalidation. The ctx.invalidate() escape hatch is removed in favour of automatic tracking on ctx.unsafeRawDb.

  • Cascade isolation is correctly restored: trackExec discriminates queryType === \"delete\" for cascadeWrite and uses plain tablesWritten.add for INSERT/UPDATE; integration tests explicitly assert this separation.
  • Drizzle SQL template support added in createTrackedRawDb: the queryChunks branch converts SQL objects via target.dialect?.sqlToQuery before parsing, closing the silent-failure gap for sql\\...\`` invocations.
  • trackWithRelations uses relation key names, not physical table names: subscriptions created for nested with options (e.g. with: { user: true } from postsRelations) hash \"user\" rather than \"users\", so those sessions will not receive invalidations when the underlying table is written.

Confidence Score: 4/5

The core refactor is functionally correct and well-tested, but the relation-name-vs-table-name subscription gap in trackWithRelations means sessions subscribing through nested with options whose relation key differs from the physical table name will silently miss invalidations.

Cascade isolation, the .withoutWhere() escape hatch, addListener deduplication, and Drizzle SQL template handling are all correctly addressed and verified by a comprehensive integration test suite. The remaining gap is trackWithRelations using relation property names instead of physical table names, causing with: { user: true } to hash 'user' rather than 'users' and miss invalidations when the users table is mutated.

packages/server/src/tools/createSafetyProxy.ts (trackWithRelations) and packages/server/src/tools/createBuilderProxy.ts (isBuilder predicate)

Important Files Changed

Filename Overview
packages/server/src/tools/createBuilderProxy.ts New unified builder proxy handling select/insert/update/delete. The isBuilder predicate is too permissive and can wrap non-builder return values in proxies.
packages/server/src/tools/parseSqlTracking.ts New AST-based SQL parser for table and WHERE ID extraction. VariableExpr offset-to-param-index mapping can silently produce wrong mappings when sqlite3-parser omits span offsets.
packages/server/src/tools/tracking.ts New tracking module. Cascade is correctly limited to DELETE operations only; integration tests confirm INSERT and UPDATE do not cascade.
packages/server/src/tools/createSafetyProxy.ts New top-level proxy replacing createTrackedDb. trackWithRelations uses relation property names rather than physical table names, so subscriptions for relations whose key differs from the table name miss invalidations.
packages/server/src/tools/createTrackedRawDb.ts Wraps unsafeRawDb to auto-track raw SQL. If target.dialect is absent the fallback String(firstArg) produces [object Object] and tracking silently fails.
packages/server/src/tools/buildPkMap.ts Builds a map of table name to PK column names from Drizzle schema using the correct runtime c.primary property.
packages/server/src/tools/safetyProxy.integration.test.ts Comprehensive integration tests covering limits, WHERE enforcement, table tracking, cascade isolation, relation tracking, and row ID tracking against a real SQLite database.
packages/server/src/server/do.ts Replaces createTrackedDb with createSafetyProxy + createTrackedRawDb, adds rowIds/pkMap tracking context, removes ctx.invalidate() in favour of automatic SQL-parsing-based tracking.
packages/server/src/server/index.ts Propagates the new rows meta (_meta.r) from RPC result to HTTP response, hashing table names before exposing to clients.
packages/server/src/tools/parseSqlTracking.test.ts Thorough unit tests for parseSqlTracking covering SELECT, INSERT, UPDATE, DELETE, JOINs, subqueries, CTEs, and WHERE ID extraction.

Sequence Diagram

sequenceDiagram
    participant User as User Code
    participant SP as createSafetyProxy
    participant BP as createBuilderProxy
    participant TE as trackExec
    participant PS as parseSqlTracking
    participant DB as Drizzle rawDb

    User->>SP: "ctx.db.insert(users).values({})"
    SP->>BP: createBuilderProxy(target, ctx, insert)
    User->>BP: await
    BP->>TE: trackExec(builder, ctx, tableName, insert)
    TE->>DB: builder.toSQL()
    TE->>PS: parseSqlTracking(sql, params)
    PS-->>TE: "tablesWritten=[users]"
    TE->>TE: tablesWritten.add(users)
    BP->>DB: builder.then(resolve, reject)
    DB-->>User: result
    Note over User,DB: broadcastInvalidations on success
Loading

Reviews (5): Last reviewed commit: "Refactor table assignment logic for clar..." | Re-trigger Greptile

Comment thread packages/server/src/tools/createSafetyProxy.ts
Comment thread packages/server/src/tools/createBuilderProxy.ts
Comment thread packages/server/src/tools/createSafetyProxy.ts Outdated
Comment on lines +8 to +9
const config = getTableConfig(table as any);
const pkCols = config.columns.filter((c: any) => c.primary).map((c: any) => c.name);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 c.primary may not be the correct Drizzle column property

config.columns.filter((c) => c.primary) uses the .primary field to detect primary-key columns. In the Drizzle ORM SQLite adapter the canonical property exposed by getTableConfig is c.primaryKey (a boolean), not c.primary. If the property name is wrong, every pkCols array will be empty and pkMap will be populated but useless for future consumers. Worth verifying against the installed Drizzle version before pkMap is wired into invalidation logic.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proof that .primary is correct and .primaryKey is wrong:

  1. Constructor source (drizzle-orm/column.cjs:36):
    this.primary = config.primaryKey;
    The constructor maps config.primaryKey (config-time property) to this.primary (instance property).
  2. Runtime verification:
    const config = getTableConfig(users);
    for (const col of config.columns) {
    console.log(col.name, '| primary:', col.primary, '| primaryKey:', col.primaryKey);
    }
    Output:
    id | primary: true | primaryKey: undefined
    name | primary: false | primaryKey: undefined
  • col.primary → true/false (correct, works)
  • col.primaryKey → undefined always (wrong, broken)
    Greptile confused the config-time property name (primaryKey in the builder API: integer("id").primaryKey()) with the runtime instance property name (primary on the column object returned by getTableConfig). The code correctly uses the runtime property .primary.

Comment thread packages/server/src/tools/createTrackedRawDb.ts Outdated
Comment thread packages/server/src/tools/tracking.ts Outdated
const { sql, params } = builder.toSQL();
const parsed = parseSqlTracking(sql, params);
for (const t of parsed.tablesRead) addListener(t, ctx);
for (const t of parsed.tablesWritten) cascadeWrite(t, ctx.tablesWritten, ctx.cascadeGraph);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 CASCADE fires for INSERT and UPDATE — regression from old behaviour

The old code only applied the cascade graph to DELETE: createInsertProxy passed new Map() as the cascade graph, and createUpdateBuilderProxy also passed new Map(). Only createMutationProxy for "delete" received the live cascadeGraph. trackExec now calls cascadeWrite(t, ctx.tablesWritten, ctx.cascadeGraph) for every table in parsed.tablesWritten, which includes INSERT and UPDATE operations.

Concrete failure: with cascadeGraph.get("users") = Set{posts} (because posts has onDelete: "cascade"), calling db.insert(users).values({...}) marks posts as written, so every subscriber of posts receives an invalidation even though no posts row was touched. The deleted integration tests (proxy.integration.test.ts) explicitly asserted expect(tablesWritten.has("posts")).toBe(false) for both insert and update, and those assertions would now fail.

Only cascade writes on DELETE operations; INSERT and UPDATE only mark
the directly affected table.
Comment thread packages/server/src/tools/createBuilderProxy.ts
The `.withoutWhere()` call was being invoked twice — once before
passing to `wrap()` and implicitly again when the proxy intercepts it.
Pass the unwrapped builder instead and let the proxy handle method
interception normally.

Also add `inArray` import and `posts` to pkMap in test setup, and
improve row ID tracking to infer table from single-table writes and
filter by primary key columns only.
@digitalmio digitalmio closed this May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant