From 3e147ca487a3a37e86b1211fcc7ce87a90ba890b Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:14:03 -0600 Subject: [PATCH 1/3] fix(fmodata): unquoted date values in OData filters + Database.from() mutation bug Date/time/timestamp values are now emitted unquoted in OData $filter. Database.from() no longer mutates shared _useEntityIds state. --- .changeset/fix-date-filter-quoting.md | 5 +++ packages/fmodata/src/client/database.ts | 7 ++-- packages/fmodata/src/client/entity-set.ts | 5 +-- packages/fmodata/src/orm/column.ts | 5 +++ packages/fmodata/src/orm/operators.ts | 6 ++++ packages/fmodata/src/orm/table.ts | 2 ++ packages/fmodata/tests/filters.test.ts | 41 +++++++++++++++++++++++ 7 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-date-filter-quoting.md diff --git a/.changeset/fix-date-filter-quoting.md b/.changeset/fix-date-filter-quoting.md new file mode 100644 index 00000000..8171e41e --- /dev/null +++ b/.changeset/fix-date-filter-quoting.md @@ -0,0 +1,5 @@ +--- +"@proofkit/fmodata": patch +--- + +Fix unquoted date/time/timestamp values in OData filters and fix `Database.from()` mutating shared `_useEntityIds` state diff --git a/packages/fmodata/src/client/database.ts b/packages/fmodata/src/client/database.ts index c574a830..47f9da6e 100644 --- a/packages/fmodata/src/client/database.ts +++ b/packages/fmodata/src/client/database.ts @@ -85,13 +85,13 @@ export class Database { // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration from>(table: T): EntitySet { - // Only override database-level useEntityIds if table explicitly sets it - // (not if it's undefined, which would override the database setting) + // Resolve useEntityIds per-call without mutating shared Database state + let useEntityIds = this._useEntityIds; if (Object.hasOwn(table, FMTable.Symbol.UseEntityIds)) { // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access const tableUseEntityIds = (table as any)[FMTable.Symbol.UseEntityIds]; if (typeof tableUseEntityIds === "boolean") { - this._useEntityIds = tableUseEntityIds; + useEntityIds = tableUseEntityIds; } } return new EntitySet({ @@ -99,6 +99,7 @@ export class Database { databaseName: this.databaseName, context: this.context, database: this, + useEntityIds, }); } diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts index 63273a7a..2cac622b 100644 --- a/packages/fmodata/src/client/entity-set.ts +++ b/packages/fmodata/src/client/entity-set.ts @@ -57,13 +57,14 @@ export class EntitySet, DatabaseIncludeSpecialColu context: ExecutionContext; // biome-ignore lint/suspicious/noExplicitAny: Database type is optional and can be any Database instance database?: any; + useEntityIds?: boolean; }) { this.occurrence = config.occurrence; this.databaseName = config.databaseName; this.context = config.context; this.database = config.database; - // Get useEntityIds from database if available, otherwise default to false - this.databaseUseEntityIds = config.database?._getUseEntityIds ?? false; + // Use explicit useEntityIds if provided, otherwise fall back to database setting + this.databaseUseEntityIds = config.useEntityIds ?? config.database?._getUseEntityIds ?? false; // Get includeSpecialColumns from database if available, otherwise default to false this.databaseIncludeSpecialColumns = (config.database?._getIncludeSpecialColumns ?? false) as DatabaseIncludeSpecialColumns; diff --git a/packages/fmodata/src/orm/column.ts b/packages/fmodata/src/orm/column.ts index 1344b259..ad8da914 100644 --- a/packages/fmodata/src/orm/column.ts +++ b/packages/fmodata/src/orm/column.ts @@ -23,6 +23,7 @@ export class Column< readonly tableEntityId?: `FMTID:${string}`; // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer readonly inputValidator?: StandardSchemaV1; + readonly fieldType?: string; // Phantom types for TypeScript inference - never actually hold values readonly _phantomOutput!: TOutput; @@ -36,12 +37,14 @@ export class Column< tableEntityId?: `FMTID:${string}`; // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer inputValidator?: StandardSchemaV1; + fieldType?: string; }) { this.fieldName = config.fieldName; this.entityId = config.entityId; this.tableName = config.tableName; this.tableEntityId = config.tableEntityId; this.inputValidator = config.inputValidator; + this.fieldType = config.fieldType; } /** @@ -115,6 +118,7 @@ export class ColumnFunction< tableName: innerColumn.tableName, tableEntityId: innerColumn.tableEntityId, inputValidator: innerColumn.inputValidator, + fieldType: innerColumn.fieldType, }); this.fnName = fnName; this.innerColumn = innerColumn; @@ -152,6 +156,7 @@ export function createColumn; + fieldType?: string; }): Column { return new Column(config) as Column; } diff --git a/packages/fmodata/src/orm/operators.ts b/packages/fmodata/src/orm/operators.ts index e3f9a6e3..039bf752 100644 --- a/packages/fmodata/src/orm/operators.ts +++ b/packages/fmodata/src/orm/operators.ts @@ -187,6 +187,12 @@ export class FilterExpression { } } + // Date/time/timestamp values must be unquoted in OData filters + const ft = column?.fieldType; + if (ft === "date" || ft === "time" || ft === "timestamp") { + return String(value); + } + if (typeof value === "string") { return `'${value.replace(/'/g, "''")}'`; // Escape single quotes } diff --git a/packages/fmodata/src/orm/table.ts b/packages/fmodata/src/orm/table.ts index dbe0bb99..1dacad2b 100644 --- a/packages/fmodata/src/orm/table.ts +++ b/packages/fmodata/src/orm/table.ts @@ -396,6 +396,7 @@ export function fmTableOccurrence< tableName: name, tableEntityId: options?.entityId, inputValidator: config.inputValidator, + fieldType: config.fieldType, }); } @@ -734,6 +735,7 @@ export function getTableColumns>( tableName, tableEntityId, inputValidator: config.inputValidator, + fieldType: config.fieldType, }); } diff --git a/packages/fmodata/tests/filters.test.ts b/packages/fmodata/tests/filters.test.ts index 9ed45811..14ec8e0d 100644 --- a/packages/fmodata/tests/filters.test.ts +++ b/packages/fmodata/tests/filters.test.ts @@ -16,6 +16,7 @@ import { and, contains, + dateField, endsWith, eq, fmTableOccurrence, @@ -31,6 +32,7 @@ import { or, startsWith, textField, + timestampField, tolower, toupper, trim, @@ -527,4 +529,43 @@ describe("Filter Tests", () => { const query = db.from(usersTOWithIds).list().where(eq(tolower(usersTOWithIds.name), "john")); expect(query.getQueryString()).toContain("tolower(FMFID:6) eq 'john'"); }); + + it("should not quote date values in filters", () => { + // FileMaker OData API expects date values WITHOUT single quotes: + // invoiceDate gt 2024-01-01 ✅ correct + // invoiceDate gt '2024-01-01' ❌ FileMaker gets confused + // + // Currently dateField() input type is string, so the value hits the + // string branch in _operandToString and gets single-quoted. This test + // documents the desired behavior. + const dateTable = fmTableOccurrence("invoices", { + id: textField().primaryKey(), + invoiceDate: dateField(), + dueDate: dateField(), + createdAt: timestampField(), + }); + // Fresh db to avoid state pollution from prior tests mutating useEntityIds + const freshDb = createMockClient().database("test.fmp12"); + + const gtQuery = freshDb.from(dateTable).list().where(gt(dateTable.invoiceDate, "2024-01-01")); + expect(gtQuery.getQueryString()).toContain("invoiceDate gt 2024-01-01"); + + const ltQuery = freshDb.from(dateTable).list().where(lt(dateTable.dueDate, "2025-12-31")); + expect(ltQuery.getQueryString()).toContain("dueDate lt 2025-12-31"); + + const gteQuery = freshDb.from(dateTable).list().where(gte(dateTable.invoiceDate, "2024-06-15")); + expect(gteQuery.getQueryString()).toContain("invoiceDate ge 2024-06-15"); + + const lteQuery = freshDb.from(dateTable).list().where(lte(dateTable.dueDate, "2024-06-15")); + expect(lteQuery.getQueryString()).toContain("dueDate le 2024-06-15"); + + const eqQuery = freshDb.from(dateTable).list().where(eq(dateTable.invoiceDate, "2024-01-01")); + expect(eqQuery.getQueryString()).toContain("invoiceDate eq 2024-01-01"); + + const tsQuery = freshDb + .from(dateTable) + .list() + .where(gt(dateTable.createdAt, "2024-01-01T00:00:00Z")); + expect(tsQuery.getQueryString()).toContain("createdAt gt 2024-01-01T00:00:00Z"); + }); }); From e902501ae3e96dff819d7e566921c9cb71a0055f Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:18:32 -0600 Subject: [PATCH 2/3] =?UTF-8?q?fix(fmodata):=20lint=20fixes=20=E2=80=94=20?= =?UTF-8?q?readonly=20=5FuseEntityIds=20+=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/fmodata/src/client/database.ts | 2 +- packages/fmodata/src/orm/column.ts | 9 +---- packages/fmodata/tests/filters.test.ts | 52 ++++++++++++++++++------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/fmodata/src/client/database.ts b/packages/fmodata/src/client/database.ts index 47f9da6e..222d1a57 100644 --- a/packages/fmodata/src/client/database.ts +++ b/packages/fmodata/src/client/database.ts @@ -25,7 +25,7 @@ export class Database { readonly webhook: WebhookManager; private readonly databaseName: string; private readonly context: ExecutionContext; - private _useEntityIds: boolean; + private readonly _useEntityIds: boolean; private readonly _includeSpecialColumns: IncludeSpecialColumns; constructor( diff --git a/packages/fmodata/src/orm/column.ts b/packages/fmodata/src/orm/column.ts index ad8da914..f2db318e 100644 --- a/packages/fmodata/src/orm/column.ts +++ b/packages/fmodata/src/orm/column.ts @@ -108,10 +108,7 @@ export class ColumnFunction< readonly fnName: string; readonly innerColumn: Column; - constructor( - fnName: string, - innerColumn: Column, - ) { + constructor(fnName: string, innerColumn: Column) { super({ fieldName: innerColumn.fieldName, entityId: innerColumn.entityId, @@ -129,9 +126,7 @@ export class ColumnFunction< return `${this.fnName}(${this.innerColumn.toFilterString(useEntityIds)})`; } const fieldIdentifier = this.innerColumn.getFieldIdentifier(useEntityIds); - const quoted = needsFieldQuoting(fieldIdentifier) - ? `"${fieldIdentifier}"` - : fieldIdentifier; + const quoted = needsFieldQuoting(fieldIdentifier) ? `"${fieldIdentifier}"` : fieldIdentifier; return `${this.fnName}(${quoted})`; } } diff --git a/packages/fmodata/tests/filters.test.ts b/packages/fmodata/tests/filters.test.ts index 14ec8e0d..4ab706a3 100644 --- a/packages/fmodata/tests/filters.test.ts +++ b/packages/fmodata/tests/filters.test.ts @@ -482,22 +482,34 @@ describe("Filter Tests", () => { }); it("should support tolower transform with eq", () => { - const query = db.from(contacts).list().where(eq(tolower(contacts.name), "john")); + const query = db + .from(contacts) + .list() + .where(eq(tolower(contacts.name), "john")); expect(query.getQueryString()).toContain("tolower(name) eq 'john'"); }); it("should support toupper transform with eq", () => { - const query = db.from(contacts).list().where(eq(toupper(contacts.name), "JOHN")); + const query = db + .from(contacts) + .list() + .where(eq(toupper(contacts.name), "JOHN")); expect(query.getQueryString()).toContain("toupper(name) eq 'JOHN'"); }); it("should support trim transform with eq", () => { - const query = db.from(contacts).list().where(eq(trim(contacts.name), "John")); + const query = db + .from(contacts) + .list() + .where(eq(trim(contacts.name), "John")); expect(query.getQueryString()).toContain("trim(name) eq 'John'"); }); it("should support nested transforms", () => { - const query = db.from(contacts).list().where(eq(tolower(trim(contacts.name)), "john")); + const query = db + .from(contacts) + .list() + .where(eq(tolower(trim(contacts.name)), "john")); expect(query.getQueryString()).toContain("tolower(trim(name)) eq 'john'"); }); @@ -510,23 +522,38 @@ describe("Filter Tests", () => { }, { defaultSelect: "all" }, ); - const query = db.from(weirdTable).list().where(eq(tolower(weirdTable["name with spaces"]), "john")); - expect(query.getQueryString()).toContain('tolower("name with spaces") eq \'john\''); + const query = db + .from(weirdTable) + .list() + .where(eq(tolower(weirdTable["name with spaces"]), "john")); + expect(query.getQueryString()).toContain("tolower(\"name with spaces\") eq 'john'"); }); it("should support transforms with other operators", () => { - const containsQuery = db.from(contacts).list().where(contains(tolower(contacts.name), "john")); + const containsQuery = db + .from(contacts) + .list() + .where(contains(tolower(contacts.name), "john")); expect(containsQuery.getQueryString()).toContain("contains(tolower(name), 'john')"); - const startsQuery = db.from(contacts).list().where(startsWith(toupper(contacts.name), "J")); + const startsQuery = db + .from(contacts) + .list() + .where(startsWith(toupper(contacts.name), "J")); expect(startsQuery.getQueryString()).toContain("startswith(toupper(name), 'J')"); - const neQuery = db.from(contacts).list().where(ne(trim(contacts.name), "John")); + const neQuery = db + .from(contacts) + .list() + .where(ne(trim(contacts.name), "John")); expect(neQuery.getQueryString()).toContain("trim(name) ne 'John'"); }); it("should support transforms with entity IDs", () => { - const query = db.from(usersTOWithIds).list().where(eq(tolower(usersTOWithIds.name), "john")); + const query = db + .from(usersTOWithIds) + .list() + .where(eq(tolower(usersTOWithIds.name), "john")); expect(query.getQueryString()).toContain("tolower(FMFID:6) eq 'john'"); }); @@ -562,10 +589,7 @@ describe("Filter Tests", () => { const eqQuery = freshDb.from(dateTable).list().where(eq(dateTable.invoiceDate, "2024-01-01")); expect(eqQuery.getQueryString()).toContain("invoiceDate eq 2024-01-01"); - const tsQuery = freshDb - .from(dateTable) - .list() - .where(gt(dateTable.createdAt, "2024-01-01T00:00:00Z")); + const tsQuery = freshDb.from(dateTable).list().where(gt(dateTable.createdAt, "2024-01-01T00:00:00Z")); expect(tsQuery.getQueryString()).toContain("createdAt gt 2024-01-01T00:00:00Z"); }); }); From 88aa52b0e5d408c6ff4c3cf181a4b2c1fbc59ba5 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:27:18 -0600 Subject: [PATCH 3/3] ci: parallelize lint/typecheck/test/build in CI workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/continuous-release.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-release.yml b/.github/workflows/continuous-release.yml index a8524614..1d55edc3 100644 --- a/.github/workflows/continuous-release.yml +++ b/.github/workflows/continuous-release.yml @@ -53,7 +53,6 @@ jobs: test: if: github.ref != 'refs/heads/beads-sync' runs-on: ubuntu-latest - needs: [lint, typecheck] steps: - name: Checkout code uses: actions/checkout@v4 @@ -73,7 +72,26 @@ jobs: build: if: github.ref != 'refs/heads/beads-sync' runs-on: ubuntu-latest - needs: [test] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + publish: + if: github.ref != 'refs/heads/beads-sync' + runs-on: ubuntu-latest + needs: [lint, typecheck, test, build] steps: - name: Checkout code uses: actions/checkout@v4