From 7906ee8d49a2899d27156d41c32ce99fc062b6d7 Mon Sep 17 00:00:00 2001
From: Eric Luce <37158449+eluce2@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:02:30 -0700
Subject: [PATCH] Add ROWID record locators to fmodata
---
.changeset/fresh-falcons-grow.md | 9 ++
apps/docs/content/docs/fmodata/crud.mdx | 14 +++
.../content/docs/fmodata/extra-properties.mdx | 6 ++
apps/docs/content/docs/fmodata/methods.mdx | 2 +
apps/docs/src/app/(home)/page.tsx | 81 ++++++------------
apps/docs/src/app/docs/(docs)/layout.tsx | 7 +-
apps/docs/src/app/docs/templates/layout.tsx | 7 +-
packages/fmodata/README.md | 29 +++++++
.../fmodata/skills/fmodata-client/SKILL.md | 20 +++++
.../src/client/builders/mutation-helpers.ts | 36 ++++++--
packages/fmodata/src/client/delete-builder.ts | 25 ++++--
packages/fmodata/src/client/entity-set.ts | 15 +++-
.../fmodata/src/client/query/url-builder.ts | 36 +++++---
packages/fmodata/src/client/record-builder.ts | 85 ++++++-------------
packages/fmodata/src/client/update-builder.ts | 27 ++++--
packages/fmodata/tests/delete.test.ts | 19 +++++
.../fmodata/tests/mutation-helpers.test.ts | 20 ++++-
packages/fmodata/tests/navigate.test.ts | 7 ++
.../record-builder-select-expand.test.ts | 7 ++
packages/fmodata/tests/typescript.test.ts | 3 +
packages/fmodata/tests/update.test.ts | 20 +++++
21 files changed, 318 insertions(+), 157 deletions(-)
create mode 100644 .changeset/fresh-falcons-grow.md
diff --git a/.changeset/fresh-falcons-grow.md b/.changeset/fresh-falcons-grow.md
new file mode 100644
index 00000000..72d788dc
--- /dev/null
+++ b/.changeset/fresh-falcons-grow.md
@@ -0,0 +1,9 @@
+---
+"@proofkit/fmodata": minor
+---
+
+Add `ROWID` record locator support to `fmodata` single-record APIs.
+
+- Allow `db.from(table).get({ ROWID: 2 })`
+- Add `update(data).byRowId(2)`
+- Add `delete().byRowId(2)`
diff --git a/apps/docs/content/docs/fmodata/crud.mdx b/apps/docs/content/docs/fmodata/crud.mdx
index 3af5096e..1186708d 100644
--- a/apps/docs/content/docs/fmodata/crud.mdx
+++ b/apps/docs/content/docs/fmodata/crud.mdx
@@ -64,6 +64,13 @@ if (result.data) {
console.log(`Updated ${result.data.updatedCount} record(s)`);
}
+// Update by ROWID
+const byRowId = await db
+ .from(users)
+ .update({ username: "newname" })
+ .byRowId(2)
+ .execute();
+
// Update by filter (using ORM API)
import { lt, and, eq } from "@proofkit/fmodata";
@@ -85,6 +92,10 @@ const result = await db
All fields are optional for updates (except read-only fields which are automatically excluded). TypeScript will enforce that you can only update fields that aren't marked as read-only.
+
+For webhook hydrate flows, use `db.from(table).get({ ROWID })` when you only have FileMaker `ROWID` metadata.
+
+
## Delete
Delete records by ID or filter:
@@ -97,6 +108,9 @@ if (result.data) {
console.log(`Deleted ${result.data.deletedCount} record(s)`);
}
+// Delete by ROWID
+const byRowId = await db.from(users).delete().byRowId(2).execute();
+
// Delete by filter (using ORM API)
import { eq, and, lt } from "@proofkit/fmodata";
diff --git a/apps/docs/content/docs/fmodata/extra-properties.mdx b/apps/docs/content/docs/fmodata/extra-properties.mdx
index 03f9ca62..dadf66e5 100644
--- a/apps/docs/content/docs/fmodata/extra-properties.mdx
+++ b/apps/docs/content/docs/fmodata/extra-properties.mdx
@@ -34,6 +34,12 @@ const result = await db.from(users).list().execute({
});
```
+Use `ROWID` to hydrate a record when a webhook payload only gives you system columns:
+
+```typescript
+const result = await db.from(users).get({ ROWID: 2 }).execute();
+```
+
Special columns are only included when no `$select` query is applied (per OData specification). When using `.select()`, special columns are excluded even if `includeSpecialColumns` is enabled.
diff --git a/apps/docs/content/docs/fmodata/methods.mdx b/apps/docs/content/docs/fmodata/methods.mdx
index 3aa0c5d3..d2e757bf 100644
--- a/apps/docs/content/docs/fmodata/methods.mdx
+++ b/apps/docs/content/docs/fmodata/methods.mdx
@@ -13,6 +13,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`.
|--------|-------------|---------|
| `list()` | Retrieve multiple records | `db.from(users).list().execute()` |
| `get(id)` | Get a single record by ID | `db.from(users).get("user-123").execute()` |
+| `get({ ROWID })` | Get a single record by FileMaker `ROWID` | `db.from(users).get({ ROWID: 2 }).execute()` |
| `getSingleField(column)` | Get a single field value | `db.from(users).get("user-123").getSingleField(users.email).execute()` |
| `single()` | Ensure exactly one record | `db.from(users).list().where(eq(...)).single().execute()` |
| `maybeSingle()` | Get at most one record (returns null if none) | `db.from(users).list().where(eq(...)).maybeSingle().execute()` |
@@ -24,6 +25,7 @@ Quick reference for all available methods and operators in `@proofkit/fmodata`.
| `insert(data)` | Insert a new record | `db.from(users).insert({ username: "john" }).execute()` |
| `update(data)` | Update records | `db.from(users).update({ active: true }).byId("user-123").execute()` |
| `delete()` | Delete records | `db.from(users).delete().byId("user-123").execute()` |
+| `byRowId(rowId)` | Target a single record by FileMaker `ROWID` | `db.from(users).update({ active: true }).byRowId(2).execute()` |
## Query Modifiers
diff --git a/apps/docs/src/app/(home)/page.tsx b/apps/docs/src/app/(home)/page.tsx
index 4a0a14f4..c6adc8c4 100644
--- a/apps/docs/src/app/(home)/page.tsx
+++ b/apps/docs/src/app/(home)/page.tsx
@@ -26,51 +26,34 @@ export default function HomePage() {
-
+
-
- A collection of tools for FileMaker-aware TypeScript applications
-
+
A collection of tools for FileMaker-aware TypeScript applications
- For new and experienced developers alike, the ProofKit toolset is
- the best way to build web apps connected to FileMaker data, or rich,
- interactive interfaces in a FileMaker webviewer.
+ For new and experienced developers alike, the ProofKit toolset is the best way to build web apps connected
+ to FileMaker data, or rich, interactive interfaces in a FileMaker webviewer.
} title="ProofKit CLI">
- A command line tool to start a new project, or easily apply
- templates and common patterns with{" "}
- no JavaScript experience{" "}
- required.
+ A command line tool to start a new project, or easily apply templates and common patterns with{" "}
+ no JavaScript experience required.
} title={"Typegen"}>
- Automatically generate runtime validators and TypeScript files
- from your own FileMaker layouts or table occurrences.
+ Automatically generate runtime validators and TypeScript files from your own FileMaker layouts or table
+ occurrences.
- }
- title="Filemaker Data API"
- >
- A type-safe API for your FileMaker layouts. Easily connect without
- worrying about token management.
+ } title="Filemaker Data API">
+ A type-safe API for your FileMaker layouts. Easily connect without worrying about token management.
}
>
- A strongly-typed OData API client with full TypeScript inference,
- runtime validation, and a fluent query builder.
+ A strongly-typed OData API client with full TypeScript inference, runtime validation, and a fluent query
+ builder.
- }
- title="FileMaker Webviewer"
- >
- Use async functions in WebViewer code to execute and get the
- result of a FileMaker script.
+ } title="FileMaker Webviewer">
+ Use async functions in WebViewer code to execute and get the result of a FileMaker script.
}
>
- Own your authentication with FileMaker and the extensible
- Better-Auth framework.
+ Own your authentication with FileMaker and the extensible Better-Auth framework.
@@ -118,8 +95,7 @@ export default function HomePage() {
Quick Start
- Use the ProofKit CLI to launch a full-featured Next.js app in
- minutes—no prior experience required.
+ Use the ProofKit CLI to launch a full-featured Next.js app in minutes—no prior experience required.
@@ -152,11 +128,9 @@ export default function HomePage() {
Built for AI Agents
- Every ProofKit package ships with agent skills — built from
- decades of combined FileMaker integration experience at Proof —
- that give AI coding tools like Claude Code and Cursor the
- context they need to write correct, production-ready FileMaker
- code from day one.
+ Every ProofKit package ships with agent skills — built from decades of combined FileMaker integration
+ experience at Proof — that give AI coding tools like Claude Code and Cursor the context they need to
+ write correct, production-ready FileMaker code from day one.
@@ -167,9 +141,8 @@ export default function HomePage() {
Expert knowledge built in
- Agent skills cover API patterns, edge cases, and common
- mistakes so your AI agent avoids the pitfalls that trip up
- even experienced developers.
+ Agent skills cover API patterns, edge cases, and common mistakes so your AI agent avoids the pitfalls
+ that trip up even experienced developers.
@@ -178,9 +151,8 @@ export default function HomePage() {
Type-safe by default
- Schemas generated from your FileMaker field names plus runtime
- validators catch bugs early — whether code is written by you
- or your AI agent.
+ Schemas generated from your FileMaker field names plus runtime validators catch bugs early — whether
+ code is written by you or your AI agent.
@@ -189,9 +161,8 @@ export default function HomePage() {
Works with any agent
- Skills are bundled with each package — just install and your
- AI coding tool picks them up automatically. Compatible with
- Claude Code, Cursor, Windsurf, and more.
+ Skills are bundled with each package — just install and your AI coding tool picks them up
+ automatically. Compatible with Claude Code, Cursor, Windsurf, and more.
diff --git a/apps/docs/src/app/docs/(docs)/layout.tsx b/apps/docs/src/app/docs/(docs)/layout.tsx
index d2ff4c32..3dd42bbc 100644
--- a/apps/docs/src/app/docs/(docs)/layout.tsx
+++ b/apps/docs/src/app/docs/(docs)/layout.tsx
@@ -16,12 +16,7 @@ export default function Layout({ children }: { children: ReactNode }) {
Made with ❤️ by{" "}
-
+
Proof
diff --git a/apps/docs/src/app/docs/templates/layout.tsx b/apps/docs/src/app/docs/templates/layout.tsx
index 6d61877e..43537437 100644
--- a/apps/docs/src/app/docs/templates/layout.tsx
+++ b/apps/docs/src/app/docs/templates/layout.tsx
@@ -86,12 +86,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
Made with ❤️ by{" "}
-
+
Proof
diff --git a/packages/fmodata/README.md b/packages/fmodata/README.md
index 78930b36..b5ecd2e1 100644
--- a/packages/fmodata/README.md
+++ b/packages/fmodata/README.md
@@ -258,6 +258,16 @@ if (result.data) {
}
```
+Get a specific record by `ROWID`:
+
+```typescript
+const result = await db.from(users).get({ ROWID: 2 }).execute();
+
+if (result.data) {
+ console.log(result.data.username);
+}
+```
+
Get a single field value:
```typescript
@@ -531,6 +541,13 @@ if (result.data) {
console.log(`Updated ${result.data.updatedCount} record(s)`);
}
+// Update by ROWID
+const byRowId = await db
+ .from(users)
+ .update({ username: "newname" })
+ .byRowId(2)
+ .execute();
+
// Update by filter (using new ORM API)
import { lt, and, eq } from "@proofkit/fmodata";
@@ -567,6 +584,9 @@ if (result.data) {
console.log(`Deleted ${result.data.deletedCount} record(s)`);
}
+// Delete by ROWID
+const byRowId = await db.from(users).delete().byRowId(2).execute();
+
// Delete by filter (using new ORM API)
import { eq, and, lt } from "@proofkit/fmodata";
@@ -1471,6 +1491,15 @@ const result = await db.from(users).list().execute({
});
```
+Use `ROWID` to hydrate a single record when you only have webhook metadata:
+
+```typescript
+const result = await db
+ .from(users)
+ .get({ ROWID: 2 })
+ .execute();
+```
+
**Important:** Special columns are only included when no `$select` query is applied (per OData specification). When using `.select()`, special columns are excluded even if `includeSpecialColumns` is enabled.
### Error Handling
diff --git a/packages/fmodata/skills/fmodata-client/SKILL.md b/packages/fmodata/skills/fmodata-client/SKILL.md
index 620531f5..1c1299c2 100644
--- a/packages/fmodata/skills/fmodata-client/SKILL.md
+++ b/packages/fmodata/skills/fmodata-client/SKILL.md
@@ -129,6 +129,9 @@ if (result.data) {
// Get single record by ID
const one = await db.from(contacts).get("abc-123").execute();
+// Get single record by FileMaker ROWID
+const byRowId = await db.from(contacts).get({ ROWID: 2 }).execute();
+
// single() -- error if != 1 result; maybeSingle() -- null if 0, error if > 1
const exact = await db
.from(contacts)
@@ -171,6 +174,13 @@ const updated = await db
.byId("abc-123")
.execute();
+// Update by ROWID
+const updatedByRowId = await db
+ .from(contacts)
+ .update({ phone: "+1-555-0100" })
+ .byRowId(2)
+ .execute();
+
// Update by filter
const bulk = await db
.from(contacts)
@@ -181,6 +191,9 @@ const bulk = await db
// Delete by ID
const deleted = await db.from(contacts).delete().byId("abc-123").execute();
+// Delete by ROWID
+const deletedByRowId = await db.from(contacts).delete().byRowId(2).execute();
+
// Delete by filter
const bulkDel = await db
.from(contacts)
@@ -230,6 +243,13 @@ const orders = await db
.navigate(invoices)
.execute();
+// Navigate starting from a ROWID-located record
+const ordersByRowId = await db
+ .from(contacts)
+ .get({ ROWID: 2 })
+ .navigate(invoices)
+ .execute();
+
// expand -- includes related records inline
const withInvoices = await db
.from(contacts)
diff --git a/packages/fmodata/src/client/builders/mutation-helpers.ts b/packages/fmodata/src/client/builders/mutation-helpers.ts
index b1572705..a275e946 100644
--- a/packages/fmodata/src/client/builders/mutation-helpers.ts
+++ b/packages/fmodata/src/client/builders/mutation-helpers.ts
@@ -10,10 +10,36 @@ const PAREN_VALUE_REGEX = /\(['"]?([^'"]+)['"]?\)/;
type MutationMode = "byId" | "byFilter";
+export interface RowIdRecordLocator {
+ ROWID: number;
+}
+
+export type RecordLocator = string | number | RowIdRecordLocator;
+
export interface FilterQueryBuilder {
getQueryString(options?: { useEntityIds?: boolean }): string;
}
+function isRowIdRecordLocator(recordLocator: RecordLocator): recordLocator is RowIdRecordLocator {
+ return typeof recordLocator === "object" && recordLocator !== null && "ROWID" in recordLocator;
+}
+
+function escapeODataStringLiteral(value: string): string {
+ return value.replaceAll("'", "''");
+}
+
+export function buildRecordLocatorSegment(recordLocator: RecordLocator): string {
+ if (isRowIdRecordLocator(recordLocator)) {
+ return `(ROWID=${recordLocator.ROWID})`;
+ }
+
+ return `('${escapeODataStringLiteral(String(recordLocator))}')`;
+}
+
+export function buildRecordPath(pathPrefix: string, recordLocator: RecordLocator): string {
+ return `${pathPrefix}${buildRecordLocatorSegment(recordLocator)}`;
+}
+
export function mergeMutationExecuteOptions(
options: (RequestInit & FFetchOptions & ExecuteOptions) | undefined,
databaseUseEntityIds: boolean,
@@ -41,18 +67,18 @@ export function buildMutationUrl(config: {
tableId: string;
tableName: string;
mode: MutationMode;
- recordId?: string | number;
+ recordLocator?: RecordLocator;
queryBuilder?: FilterQueryBuilder;
useEntityIds?: boolean;
builderName: string;
}): string {
- const { databaseName, tableId, tableName, mode, recordId, queryBuilder, useEntityIds, builderName } = config;
+ const { databaseName, tableId, tableName, mode, recordLocator, queryBuilder, useEntityIds, builderName } = config;
if (mode === "byId") {
- if (recordId === undefined || recordId === null || recordId === "") {
- throw new BuilderInvariantError(builderName, "recordId is required for byId mode");
+ if (recordLocator === undefined || recordLocator === null || recordLocator === "") {
+ throw new BuilderInvariantError(builderName, "recordLocator is required for byId mode");
}
- return `/${databaseName}/${tableId}('${recordId}')`;
+ return `/${databaseName}/${buildRecordPath(tableId, recordLocator)}`;
}
if (!queryBuilder) {
diff --git a/packages/fmodata/src/client/delete-builder.ts b/packages/fmodata/src/client/delete-builder.ts
index 8da749b4..9ed757f1 100644
--- a/packages/fmodata/src/client/delete-builder.ts
+++ b/packages/fmodata/src/client/delete-builder.ts
@@ -9,6 +9,7 @@ import {
buildMutationUrl,
extractAffectedRows,
mergeMutationExecuteOptions,
+ type RecordLocator,
resolveMutationTableId,
} from "./builders/mutation-helpers";
import { parseErrorResponse } from "./error-parser";
@@ -43,7 +44,19 @@ export class DeleteBuilder
> {
occurrence: this.table,
layer: this.layer,
mode: "byId",
- recordId: id,
+ recordLocator: id,
+ });
+ }
+
+ /**
+ * Delete a single record by ROWID
+ */
+ byRowId(rowId: number): ExecutableDeleteBuilder {
+ return new ExecutableDeleteBuilder({
+ occurrence: this.table,
+ layer: this.layer,
+ mode: "byId",
+ recordLocator: { ROWID: rowId },
});
}
@@ -80,7 +93,7 @@ export class ExecutableDeleteBuilder>
{
private readonly table: Occ;
private readonly mode: "byId" | "byFilter";
- private readonly recordId?: string | number;
+ private readonly recordLocator?: RecordLocator;
private readonly queryBuilder?: QueryBuilder;
private readonly layer: FMODataLayer;
private readonly config: ODataConfig;
@@ -89,13 +102,13 @@ export class ExecutableDeleteBuilder>
occurrence: Occ;
layer: FMODataLayer;
mode: "byId" | "byFilter";
- recordId?: string | number;
+ recordLocator?: RecordLocator;
queryBuilder?: QueryBuilder;
}) {
this.table = config.occurrence;
this.layer = config.layer;
this.mode = config.mode;
- this.recordId = config.recordId;
+ this.recordLocator = config.recordLocator;
this.queryBuilder = config.queryBuilder;
this.config = createClientRuntime(this.layer).config;
}
@@ -111,7 +124,7 @@ export class ExecutableDeleteBuilder>
tableId,
tableName: getTableName(this.table),
mode: this.mode,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds,
builderName: "ExecutableDeleteBuilder",
@@ -141,7 +154,7 @@ export class ExecutableDeleteBuilder>
tableId,
tableName: getTableName(this.table),
mode: this.mode,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds: this.config.useEntityIds,
builderName: "ExecutableDeleteBuilder",
diff --git a/packages/fmodata/src/client/entity-set.ts b/packages/fmodata/src/client/entity-set.ts
index 40d0e6ca..6ccea1ee 100644
--- a/packages/fmodata/src/client/entity-set.ts
+++ b/packages/fmodata/src/client/entity-set.ts
@@ -17,6 +17,7 @@ import {
isUsingEntityIds,
} from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
+import type { RowIdRecordLocator } from "./builders/mutation-helpers";
import { resolveTableId } from "./builders/table-utils";
import type { Database } from "./database";
import { DeleteBuilder } from "./delete-builder";
@@ -173,9 +174,15 @@ export class EntitySet, DatabaseIncludeSpecialColu
}
get(
- id: string | number,
- // biome-ignore lint/complexity/noBannedTypes: Empty object type represents no expands by default
- ): RecordBuilder, {}, DatabaseIncludeSpecialColumns> {
+ locator: RowIdRecordLocator | string | number,
+ ): RecordBuilder<
+ Occ,
+ false,
+ undefined,
+ keyof InferSchemaOutputFromFMTable,
+ Record,
+ DatabaseIncludeSpecialColumns
+ > {
const builder = new RecordBuilder<
Occ,
false,
@@ -187,7 +194,7 @@ export class EntitySet, DatabaseIncludeSpecialColu
>({
occurrence: this.occurrence,
layer: this.layer,
- recordId: id,
+ recordLocator: locator,
});
// Apply defaultSelect if occurrence exists
diff --git a/packages/fmodata/src/client/query/url-builder.ts b/packages/fmodata/src/client/query/url-builder.ts
index e3f5c9e3..8d9dc54b 100644
--- a/packages/fmodata/src/client/query/url-builder.ts
+++ b/packages/fmodata/src/client/query/url-builder.ts
@@ -1,12 +1,13 @@
import type { FMTable } from "../../orm/table";
import { getTableName } from "../../orm/table";
+import { buildRecordPath, type RecordLocator } from "../builders/mutation-helpers";
import { resolveTableId } from "../builders/table-utils";
/**
* Configuration for navigation from RecordBuilder or EntitySet
*/
export interface NavigationConfig {
- recordId?: string | number;
+ recordLocator?: RecordLocator;
relation: string;
relationEntityId?: string;
sourceTableName: string;
@@ -56,7 +57,7 @@ export class QueryUrlBuilder {
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds);
const navigation = options.navigation;
- if (navigation?.recordId && navigation?.relation) {
+ if (navigation?.recordLocator !== undefined && navigation?.relation) {
return this.buildRecordNavigation(queryString, tableId, navigation, effectiveUseEntityIds);
}
if (navigation?.relation) {
@@ -85,8 +86,13 @@ export class QueryUrlBuilder {
? (navigation.baseRelationEntityId ?? navigation.baseRelation)
: navigation.baseRelation;
const relation = useEntityIds ? (navigation.relationEntityId ?? navigation.relation) : navigation.relation;
- const { recordId } = navigation;
- const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`;
+ const { recordLocator } = navigation;
+ if (recordLocator === undefined) {
+ throw new Error("recordLocator is required for record navigation");
+ }
+ const base = baseRelation
+ ? buildRecordPath(`${sourceTable}/${baseRelation}`, recordLocator)
+ : buildRecordPath(sourceTable, recordLocator);
return `/${this.databaseName}/${base}/${relation}${queryString}`;
}
@@ -118,7 +124,7 @@ export class QueryUrlBuilder {
const navigation = options?.navigation;
const tableId = resolveTableId(this.occurrence, getTableName(this.occurrence), effectiveUseEntityIds);
- if (navigation?.recordId && navigation?.relation) {
+ if (navigation?.recordLocator !== undefined && navigation?.relation) {
const sourceTable = effectiveUseEntityIds
? (navigation.sourceTableEntityId ?? navigation.sourceTableName)
: navigation.sourceTableName;
@@ -128,8 +134,13 @@ export class QueryUrlBuilder {
const relation = effectiveUseEntityIds
? (navigation.relationEntityId ?? navigation.relation)
: navigation.relation;
- const { recordId } = navigation;
- const base = baseRelation ? `${sourceTable}/${baseRelation}('${recordId}')` : `${sourceTable}('${recordId}')`;
+ const { recordLocator } = navigation;
+ if (recordLocator === undefined) {
+ throw new Error("recordLocator is required for record navigation");
+ }
+ const base = baseRelation
+ ? buildRecordPath(`${sourceTable}/${baseRelation}`, recordLocator)
+ : buildRecordPath(sourceTable, recordLocator);
return queryString ? `/${base}/${relation}${queryString}` : `/${base}/${relation}`;
}
if (navigation?.relation) {
@@ -152,12 +163,12 @@ export class QueryUrlBuilder {
* Build URL for record operations (single record by ID).
* Used by RecordBuilder to build URLs like /database/table('id').
*
- * @param recordId - The record ID
+ * @param recordLocator - The record locator
* @param queryString - The OData query string (e.g., "?$select=...")
* @param options - Options including operation type and useEntityIds override
*/
buildRecordUrl(
- recordId: string | number,
+ recordLocator: RecordLocator,
queryString: string,
options?: {
operation?: "getSingleField";
@@ -175,10 +186,13 @@ export class QueryUrlBuilder {
let url: string;
if (options?.isNavigateFromEntitySet && options.navigateSourceTableName && options.navigateRelation) {
// From navigated EntitySet: /sourceTable/relation('recordId')
- url = `/${this.databaseName}/${options.navigateSourceTableName}/${options.navigateRelation}('${recordId}')`;
+ url = `/${this.databaseName}/${buildRecordPath(
+ `${options.navigateSourceTableName}/${options.navigateRelation}`,
+ recordLocator,
+ )}`;
} else {
// Normal record: /tableName('recordId') - use FMTID if configured
- url = `/${this.databaseName}/${tableId}('${recordId}')`;
+ url = `/${this.databaseName}/${buildRecordPath(tableId, recordLocator)}`;
}
if (options?.operation === "getSingleField" && options.operationParam) {
diff --git a/packages/fmodata/src/client/record-builder.ts b/packages/fmodata/src/client/record-builder.ts
index 4ceeb278..99febbcf 100644
--- a/packages/fmodata/src/client/record-builder.ts
+++ b/packages/fmodata/src/client/record-builder.ts
@@ -31,6 +31,7 @@ import {
processSelectWithRenames,
resolveTableId,
} from "./builders/index";
+import { buildRecordPath, type RecordLocator } from "./builders/mutation-helpers";
import { parseErrorResponse } from "./error-parser";
import type { ResolveExpandedRelations, SystemColumnsFromOption, SystemColumnsOption } from "./query/types";
import { QueryBuilder } from "./query-builder";
@@ -110,7 +111,7 @@ export class RecordBuilder<
>
{
private readonly table: Occ;
- private readonly recordId: string | number;
+ private readonly recordLocator: RecordLocator;
private readonly operation?: "getSingleField" | "navigate";
private readonly operationParam?: string;
// biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration
@@ -171,10 +172,10 @@ export class RecordBuilder<
constructor(config: {
occurrence: Occ;
layer: FMODataLayer;
- recordId: string | number;
+ recordLocator: RecordLocator;
}) {
this.table = config.occurrence;
- this.recordId = config.recordId;
+ this.recordLocator = config.recordLocator;
const runtime = createClientRuntime(config.layer);
this.layer = runtime.layer;
this.config = runtime.config;
@@ -233,7 +234,7 @@ export class RecordBuilder<
>({
occurrence: this.table,
layer: this.layer,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
});
// Use type assertion to allow assignment to readonly properties on new instance
@@ -285,7 +286,7 @@ export class RecordBuilder<
>({
occurrence: this.table,
layer: this.layer,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
});
// Use type assertion to allow assignment to readonly properties on new instance
@@ -429,7 +430,7 @@ export class RecordBuilder<
const newBuilder = new RecordBuilder({
occurrence: this.table,
layer: this.layer,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
});
// Use type assertion to allow assignment to readonly properties on new instance
@@ -533,7 +534,7 @@ export class RecordBuilder<
// biome-ignore lint/suspicious/noExplicitAny: Mutation of readonly properties for builder pattern
(builder as any).navigation = {
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
relation: relationName,
relationEntityId,
sourceTableName,
@@ -564,6 +565,21 @@ export class RecordBuilder<
});
}
+ private buildRecordResourcePath(useEntityIds: boolean): string {
+ if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
+ const sourceSegment = useEntityIds
+ ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName)
+ : this.navigateSourceTableName;
+ const relationSegment = useEntityIds
+ ? (this.navigateRelationEntityId ?? this.navigateRelation)
+ : this.navigateRelation;
+ return `/${buildRecordPath(`${sourceSegment}/${relationSegment}`, this.recordLocator)}`;
+ }
+
+ const tableId = this.getTableId(useEntityIds);
+ return `/${buildRecordPath(tableId, this.recordLocator)}`;
+ }
+
execute(
options?: ExecuteMethodOptions,
): Promise<
@@ -595,23 +611,9 @@ export class RecordBuilder<
>
> {
const mergedOptions = this.mergeExecuteOptions(options);
- let url: string;
-
- // Build the base URL depending on whether this came from a navigated EntitySet
- if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
- const sourceSegment = mergedOptions.useEntityIds
- ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName)
- : this.navigateSourceTableName;
- const relationSegment = mergedOptions.useEntityIds
- ? (this.navigateRelationEntityId ?? this.navigateRelation)
- : this.navigateRelation;
- // From navigated EntitySet: /sourceTable/relation('recordId')
- url = `/${this.config.databaseName}/${sourceSegment}/${relationSegment}('${this.recordId}')`;
- } else {
- // Normal record: /tableName('recordId') - use FMTID if configured
- const tableId = this.getTableId(mergedOptions.useEntityIds);
- url = `/${this.config.databaseName}/${tableId}('${this.recordId}')`;
- }
+ let url = `/${this.config.databaseName}${this.buildRecordResourcePath(
+ mergedOptions.useEntityIds ?? this.config.useEntityIds,
+ )}`;
if (this.operation === "getSingleField" && this.operationColumn) {
url += `/${this.operationColumn.getFieldIdentifier(mergedOptions.useEntityIds)}`;
@@ -671,23 +673,7 @@ export class RecordBuilder<
// biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
getRequestConfig(): { method: string; url: string; body?: any } {
- let url: string;
-
- // Build the base URL depending on whether this came from a navigated EntitySet
- if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
- const sourceSegment = this.config.useEntityIds
- ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName)
- : this.navigateSourceTableName;
- const relationSegment = this.config.useEntityIds
- ? (this.navigateRelationEntityId ?? this.navigateRelation)
- : this.navigateRelation;
- // From navigated EntitySet: /sourceTable/relation('recordId')
- url = `/${this.config.databaseName}/${sourceSegment}/${relationSegment}('${this.recordId}')`;
- } else {
- // For batch operations, use database-level setting (no per-request override available here)
- const tableId = this.getTableId(this.config.useEntityIds);
- url = `/${this.config.databaseName}/${tableId}('${this.recordId}')`;
- }
+ let url = `/${this.config.databaseName}${this.buildRecordResourcePath(this.config.useEntityIds)}`;
if (this.operation === "getSingleField" && this.operationColumn) {
// Use the column's getFieldIdentifier to support entity IDs
@@ -712,22 +698,7 @@ export class RecordBuilder<
*/
getQueryString(options?: { useEntityIds?: boolean }): string {
const useEntityIds = options?.useEntityIds ?? this.config.useEntityIds;
- let path: string;
-
- // Build the path depending on navigation context
- if (this.isNavigateFromEntitySet && this.navigateSourceTableName && this.navigateRelation) {
- const sourceSegment = useEntityIds
- ? (this.navigateSourceTableEntityId ?? this.navigateSourceTableName)
- : this.navigateSourceTableName;
- const relationSegment = useEntityIds
- ? (this.navigateRelationEntityId ?? this.navigateRelation)
- : this.navigateRelation;
- path = `/${sourceSegment}/${relationSegment}('${this.recordId}')`;
- } else {
- // Use getTableId to respect entity ID settings (same as getRequestConfig)
- const tableId = this.getTableId(useEntityIds);
- path = `/${tableId}('${this.recordId}')`;
- }
+ const path = this.buildRecordResourcePath(useEntityIds);
if (this.operation === "getSingleField" && this.operationColumn) {
return `${path}/${this.operationColumn.getFieldIdentifier(useEntityIds)}`;
diff --git a/packages/fmodata/src/client/update-builder.ts b/packages/fmodata/src/client/update-builder.ts
index fb614e81..b1d0e9dd 100644
--- a/packages/fmodata/src/client/update-builder.ts
+++ b/packages/fmodata/src/client/update-builder.ts
@@ -13,6 +13,7 @@ import {
buildMutationUrl,
extractAffectedRows,
mergeMutationExecuteOptions,
+ type RecordLocator,
resolveMutationTableId,
} from "./builders/mutation-helpers";
import { parseErrorResponse } from "./error-parser";
@@ -58,7 +59,21 @@ export class UpdateBuilder<
layer: this.layer,
data: this.data,
mode: "byId",
- recordId: id,
+ recordLocator: id,
+ returnPreference: this.returnPreference,
+ });
+ }
+
+ /**
+ * Update a single record by ROWID
+ */
+ byRowId(rowId: number): ExecutableUpdateBuilder {
+ return new ExecutableUpdateBuilder({
+ occurrence: this.table,
+ layer: this.layer,
+ data: this.data,
+ mode: "byId",
+ recordLocator: { ROWID: rowId },
returnPreference: this.returnPreference,
});
}
@@ -105,7 +120,7 @@ export class ExecutableUpdateBuilder<
private readonly table: Occ;
private readonly data: Partial>;
private readonly mode: "byId" | "byFilter";
- private readonly recordId?: string | number;
+ private readonly recordLocator?: RecordLocator;
private readonly queryBuilder?: QueryBuilder;
private readonly returnPreference: ReturnPreference;
private readonly layer: FMODataLayer;
@@ -116,7 +131,7 @@ export class ExecutableUpdateBuilder<
layer: FMODataLayer;
data: Partial>;
mode: "byId" | "byFilter";
- recordId?: string | number;
+ recordLocator?: RecordLocator;
queryBuilder?: QueryBuilder;
returnPreference: ReturnPreference;
}) {
@@ -124,7 +139,7 @@ export class ExecutableUpdateBuilder<
this.layer = config.layer;
this.data = config.data;
this.mode = config.mode;
- this.recordId = config.recordId;
+ this.recordLocator = config.recordLocator;
this.queryBuilder = config.queryBuilder;
this.returnPreference = config.returnPreference;
const runtime = createClientRuntime(this.layer);
@@ -146,7 +161,7 @@ export class ExecutableUpdateBuilder<
tableId,
tableName: getTableName(this.table),
mode: this.mode,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds: shouldUseIds,
builderName: "ExecutableUpdateBuilder",
@@ -217,7 +232,7 @@ export class ExecutableUpdateBuilder<
tableId,
tableName: getTableName(this.table),
mode: this.mode,
- recordId: this.recordId,
+ recordLocator: this.recordLocator,
queryBuilder: this.queryBuilder,
useEntityIds: this.config.useEntityIds,
builderName: "ExecutableUpdateBuilder",
diff --git a/packages/fmodata/tests/delete.test.ts b/packages/fmodata/tests/delete.test.ts
index 56ac18a6..871a91b2 100644
--- a/packages/fmodata/tests/delete.test.ts
+++ b/packages/fmodata/tests/delete.test.ts
@@ -49,6 +49,14 @@ describe("delete method", () => {
expect(result).toBeInstanceOf(ExecutableDeleteBuilder);
});
+ it("should return ExecutableDeleteBuilder after byRowId()", () => {
+ const mock = new MockFMServerConnection();
+ const db = mock.database("test_db");
+
+ const result = db.from(usersTO).delete().byRowId(2);
+ expect(result).toBeInstanceOf(ExecutableDeleteBuilder);
+ });
+
it("should return ExecutableDeleteBuilder after where()", () => {
const mock = new MockFMServerConnection();
const db = mock.database("test_db");
@@ -90,6 +98,17 @@ describe("delete method", () => {
db.from(usersTO).delete().byId("user-123");
});
+ it("should generate correct URL for delete by ROWID", () => {
+ const mock = new MockFMServerConnection();
+ const db = mock.database("test_db");
+
+ const deleteBuilder = db.from(usersTO).delete().byRowId(2);
+ const config = deleteBuilder.getRequestConfig();
+
+ expect(config.method).toBe("DELETE");
+ expect(config.url).toBe("/test_db/users(ROWID=2)");
+ });
+
it("should execute delete by ID and return count", async () => {
const mock = new MockFMServerConnection();
mock.addRoute({
diff --git a/packages/fmodata/tests/mutation-helpers.test.ts b/packages/fmodata/tests/mutation-helpers.test.ts
index 200be0ff..9000d3e0 100644
--- a/packages/fmodata/tests/mutation-helpers.test.ts
+++ b/packages/fmodata/tests/mutation-helpers.test.ts
@@ -1,5 +1,6 @@
import {
buildMutationUrl,
+ buildRecordLocatorSegment,
extractAffectedRows,
parseRowIdFromLocationHeader,
stripTablePathPrefix,
@@ -14,13 +15,30 @@ describe("mutation helpers", () => {
tableId: "users",
tableName: "users",
mode: "byId",
- recordId: "abc-123",
+ recordLocator: "abc-123",
builderName: "TestBuilder",
});
expect(url).toBe("/test_db/users('abc-123')");
});
+ it("builds byId mutation URLs with ROWID locator", () => {
+ const url = buildMutationUrl({
+ databaseName: "test_db",
+ tableId: "users",
+ tableName: "users",
+ mode: "byId",
+ recordLocator: { ROWID: 2 },
+ builderName: "TestBuilder",
+ });
+
+ expect(url).toBe("/test_db/users(ROWID=2)");
+ });
+
+ it("escapes string record locators for OData", () => {
+ expect(buildRecordLocatorSegment("abc'def")).toBe("('abc''def')");
+ });
+
it("builds byFilter mutation URLs and rewrites table prefix", () => {
const queryBuilder = {
getQueryString: () => "/users?$filter=name eq 'John'&$top=10",
diff --git a/packages/fmodata/tests/navigate.test.ts b/packages/fmodata/tests/navigate.test.ts
index cad75add..bd8a6c2c 100644
--- a/packages/fmodata/tests/navigate.test.ts
+++ b/packages/fmodata/tests/navigate.test.ts
@@ -90,6 +90,13 @@ describe("navigate", () => {
);
});
+ it("should support navigation from ROWID record locators", () => {
+ const db = client.database("test_db");
+ const queryBuilder = db.from(contacts).get({ ROWID: 2 }).navigate(users);
+
+ expect(queryBuilder.select({ name: users.name }).getQueryString()).toBe("/contacts(ROWID=2)/users?$select=name");
+ });
+
it("should navigate w/o needing to get a record first", () => {
const db = client.database("test_db");
const queryBuilder = db.from(contacts).navigate(users).list();
diff --git a/packages/fmodata/tests/record-builder-select-expand.test.ts b/packages/fmodata/tests/record-builder-select-expand.test.ts
index f76f44a9..ae204aa2 100644
--- a/packages/fmodata/tests/record-builder-select-expand.test.ts
+++ b/packages/fmodata/tests/record-builder-select-expand.test.ts
@@ -165,6 +165,13 @@ describe("RecordBuilder Select/Expand", () => {
expect(queryString).not.toContain("$select=");
});
+ it("should support ROWID locators in get()", () => {
+ const queryString = db.from(contacts).get({ ROWID: 2 }).getQueryString();
+
+ expect(queryString).toBe("/contacts(ROWID=2)");
+ expect(queryString).not.toContain("$select=");
+ });
+
it("should override defaultSelect when explicit select() is called", () => {
const queryString = db
.from(contactsWithSchemaSelect)
diff --git a/packages/fmodata/tests/typescript.test.ts b/packages/fmodata/tests/typescript.test.ts
index 49d1bc48..efab92a5 100644
--- a/packages/fmodata/tests/typescript.test.ts
+++ b/packages/fmodata/tests/typescript.test.ts
@@ -49,9 +49,12 @@ describe("fmodata", () => {
it("should support get() for single record retrieval", () => {
const table = db.from(contacts);
const getBuilder = table.get("my-uuid");
+ const rowIdBuilder = table.get({ ROWID: 2 });
expect(getBuilder).toBeDefined();
expect(getBuilder.getRequestConfig).toBeDefined();
+ expect(rowIdBuilder).toBeDefined();
+ expect(rowIdBuilder.getRequestConfig).toBeDefined();
});
it("should support getSingleField() API", () => {
diff --git a/packages/fmodata/tests/update.test.ts b/packages/fmodata/tests/update.test.ts
index 0ac9c89c..0015d938 100644
--- a/packages/fmodata/tests/update.test.ts
+++ b/packages/fmodata/tests/update.test.ts
@@ -153,6 +153,14 @@ describe("insert and update methods", () => {
expect(result).toBeInstanceOf(ExecutableUpdateBuilder);
});
+ it("should return ExecutableUpdateBuilder after byRowId()", () => {
+ const mock = new MockFMServerConnection();
+ const db = mock.database("test_db");
+
+ const result = db.from(users).update({ username: "newname" }).byRowId(2);
+ expect(result).toBeInstanceOf(ExecutableUpdateBuilder);
+ });
+
it("should return ExecutableUpdateBuilder after where()", () => {
const mock = new MockFMServerConnection();
const db = mock.database("test_db");
@@ -188,6 +196,18 @@ describe("insert and update methods", () => {
expectTypeOf(updateBuilder.execute).returns.resolves.toEqualTypeOf>();
});
+ it("should generate correct URL for update by ROWID", () => {
+ const mock = new MockFMServerConnection();
+ const db = mock.database("test_db");
+
+ const updateBuilder = db.from(users).update({ username: "newname" }).byRowId(2);
+ const config = updateBuilder.getRequestConfig();
+
+ expect(config.method).toBe("PATCH");
+ expect(config.url).toBe("/test_db/users(ROWID=2)");
+ expect(config.body).toBe(JSON.stringify({ username: "newname" }));
+ });
+
it("should execute update by ID and return count", async () => {
const mock = new MockFMServerConnection();
mock.addRoute({