@proofkit/fmodata 0.1.0-alpha.4 → 0.1.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +357 -28
  2. package/dist/esm/client/base-table.d.ts +122 -5
  3. package/dist/esm/client/base-table.js +46 -5
  4. package/dist/esm/client/base-table.js.map +1 -1
  5. package/dist/esm/client/database.d.ts +20 -3
  6. package/dist/esm/client/database.js +62 -13
  7. package/dist/esm/client/database.js.map +1 -1
  8. package/dist/esm/client/delete-builder.js +24 -27
  9. package/dist/esm/client/delete-builder.js.map +1 -1
  10. package/dist/esm/client/entity-set.d.ts +9 -6
  11. package/dist/esm/client/entity-set.js +5 -1
  12. package/dist/esm/client/entity-set.js.map +1 -1
  13. package/dist/esm/client/filemaker-odata.d.ts +17 -4
  14. package/dist/esm/client/filemaker-odata.js +90 -27
  15. package/dist/esm/client/filemaker-odata.js.map +1 -1
  16. package/dist/esm/client/insert-builder.js +45 -34
  17. package/dist/esm/client/insert-builder.js.map +1 -1
  18. package/dist/esm/client/query-builder.d.ts +7 -2
  19. package/dist/esm/client/query-builder.js +273 -202
  20. package/dist/esm/client/query-builder.js.map +1 -1
  21. package/dist/esm/client/record-builder.d.ts +2 -2
  22. package/dist/esm/client/record-builder.js +50 -40
  23. package/dist/esm/client/record-builder.js.map +1 -1
  24. package/dist/esm/client/table-occurrence.d.ts +66 -2
  25. package/dist/esm/client/table-occurrence.js +36 -1
  26. package/dist/esm/client/table-occurrence.js.map +1 -1
  27. package/dist/esm/client/update-builder.js +39 -35
  28. package/dist/esm/client/update-builder.js.map +1 -1
  29. package/dist/esm/errors.d.ts +60 -0
  30. package/dist/esm/errors.js +122 -0
  31. package/dist/esm/errors.js.map +1 -0
  32. package/dist/esm/index.d.ts +5 -2
  33. package/dist/esm/index.js +25 -3
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/transform.d.ts +56 -0
  36. package/dist/esm/transform.js +107 -0
  37. package/dist/esm/transform.js.map +1 -0
  38. package/dist/esm/types.d.ts +21 -5
  39. package/dist/esm/validation.d.ts +6 -3
  40. package/dist/esm/validation.js +104 -33
  41. package/dist/esm/validation.js.map +1 -1
  42. package/package.json +10 -1
  43. package/src/client/base-table.ts +155 -8
  44. package/src/client/database.ts +116 -13
  45. package/src/client/delete-builder.ts +42 -43
  46. package/src/client/entity-set.ts +21 -11
  47. package/src/client/filemaker-odata.ts +132 -34
  48. package/src/client/insert-builder.ts +69 -37
  49. package/src/client/query-builder.ts +345 -233
  50. package/src/client/record-builder.ts +84 -59
  51. package/src/client/table-occurrence.ts +118 -4
  52. package/src/client/update-builder.ts +77 -49
  53. package/src/errors.ts +185 -0
  54. package/src/index.ts +30 -1
  55. package/src/transform.ts +236 -0
  56. package/src/types.ts +112 -34
  57. package/src/validation.ts +120 -36
@@ -8,10 +8,12 @@ import type {
8
8
  } from "../types";
9
9
  import type { TableOccurrence } from "./table-occurrence";
10
10
  import type { BaseTable } from "./base-table";
11
+ import { transformTableName, transformResponseFields } from "../transform";
11
12
  import { QueryBuilder } from "./query-builder";
12
13
  import { validateSingleResponse } from "../validation";
13
14
  import { type FFetchOptions } from "@fetchkit/ffetch";
14
- import { z } from "zod/v4";
15
+ import { StandardSchemaV1 } from "@standard-schema/spec";
16
+ // import type { z } from "zod/v4";
15
17
 
16
18
  // Helper type to extract schema from a TableOccurrence
17
19
  type ExtractSchemaFromOccurrence<O> =
@@ -105,7 +107,7 @@ export class RecordBuilder<
105
107
  ): QueryBuilder<
106
108
  ExtractSchemaFromOccurrence<
107
109
  FindNavigationTarget<Occ, RelationName>
108
- > extends Record<string, z.ZodType>
110
+ > extends Record<string, StandardSchemaV1>
109
111
  ? InferSchemaType<
110
112
  ExtractSchemaFromOccurrence<FindNavigationTarget<Occ, RelationName>>
111
113
  >
@@ -127,9 +129,14 @@ export class RecordBuilder<
127
129
  context: this.context,
128
130
  });
129
131
  // Store the navigation info - we'll use it in execute
132
+ // Transform relation name to FMTID if using entity IDs
133
+ const relationId = targetOccurrence
134
+ ? transformTableName(targetOccurrence)
135
+ : relationName;
136
+
130
137
  (builder as any).isNavigate = true;
131
138
  (builder as any).navigateRecordId = this.recordId;
132
- (builder as any).navigateRelation = relationName;
139
+ (builder as any).navigateRelation = relationId;
133
140
 
134
141
  // If this RecordBuilder came from a navigated EntitySet, we need to preserve that base path
135
142
  if (
@@ -142,7 +149,11 @@ export class RecordBuilder<
142
149
  (builder as any).navigateBaseRelation = this.navigateRelation;
143
150
  } else {
144
151
  // Normal record navigation: /tableName('recordId')/relation
145
- (builder as any).navigateSourceTableName = this.tableName;
152
+ // Transform source table name to FMTID if using entity IDs
153
+ const sourceTableId = this.occurrence
154
+ ? transformTableName(this.occurrence)
155
+ : this.tableName;
156
+ (builder as any).navigateSourceTableName = sourceTableId;
146
157
  }
147
158
 
148
159
  return builder;
@@ -153,63 +164,74 @@ export class RecordBuilder<
153
164
  ): Promise<
154
165
  Result<IsSingleField extends true ? T[FieldKey] : T & ODataRecordMetadata>
155
166
  > {
156
- try {
157
- let url: string;
158
-
159
- // Build the base URL depending on whether this came from a navigated EntitySet
160
- if (
161
- this.isNavigateFromEntitySet &&
162
- this.navigateSourceTableName &&
163
- this.navigateRelation
164
- ) {
165
- // From navigated EntitySet: /sourceTable/relation('recordId')
166
- url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
167
- } else {
168
- // Normal record: /tableName('recordId')
169
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
170
- }
171
-
172
- if (this.operation === "getSingleField" && this.operationParam) {
173
- url += `/${this.operationParam}`;
174
- }
175
-
176
- const response = await this.context._makeRequest(url, options);
177
-
178
- // Handle single field operation
179
- if (this.operation === "getSingleField") {
180
- // Single field returns a JSON object with @context and value
181
- const fieldResponse = response as ODataFieldResponse<T>;
182
- return { data: fieldResponse.value as any, error: undefined };
183
- }
184
-
185
- // Get schema from occurrence if available
186
- const schema = this.occurrence?.baseTable?.schema;
187
-
188
- // Validate the single record response
189
- const validation = await validateSingleResponse<any>(
167
+ let url: string;
168
+
169
+ // Build the base URL depending on whether this came from a navigated EntitySet
170
+ if (
171
+ this.isNavigateFromEntitySet &&
172
+ this.navigateSourceTableName &&
173
+ this.navigateRelation
174
+ ) {
175
+ // From navigated EntitySet: /sourceTable/relation('recordId')
176
+ url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
177
+ } else {
178
+ // Normal record: /tableName('recordId') - use FMTID if configured
179
+ const tableId = this.occurrence
180
+ ? transformTableName(this.occurrence)
181
+ : this.tableName;
182
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
183
+ }
184
+
185
+ if (this.operation === "getSingleField" && this.operationParam) {
186
+ url += `/${this.operationParam}`;
187
+ }
188
+
189
+ const result = await this.context._makeRequest(url, options);
190
+
191
+ if (result.error) {
192
+ return { data: undefined, error: result.error };
193
+ }
194
+
195
+ let response = result.data;
196
+
197
+ // Handle single field operation
198
+ if (this.operation === "getSingleField") {
199
+ // Single field returns a JSON object with @context and value
200
+ const fieldResponse = response as ODataFieldResponse<T>;
201
+ return { data: fieldResponse.value as any, error: undefined };
202
+ }
203
+
204
+ // Transform response field IDs back to names if using entity IDs
205
+ if (this.occurrence?.baseTable) {
206
+ response = transformResponseFields(
190
207
  response,
191
- schema,
192
- undefined, // No selected fields for record.get()
193
- undefined, // No expand configs
194
- "exact", // Expect exactly one record
208
+ this.occurrence.baseTable,
209
+ undefined, // No expand configs for simple get
195
210
  );
211
+ }
212
+
213
+ // Get schema from occurrence if available
214
+ const schema = this.occurrence?.baseTable?.schema;
215
+
216
+ // Validate the single record response
217
+ const validation = await validateSingleResponse<any>(
218
+ response,
219
+ schema,
220
+ undefined, // No selected fields for record.get()
221
+ undefined, // No expand configs
222
+ "exact", // Expect exactly one record
223
+ );
196
224
 
197
- if (!validation.valid) {
198
- return { data: undefined, error: validation.error };
199
- }
200
-
201
- // Handle null response
202
- if (validation.data === null) {
203
- return { data: null as any, error: undefined };
204
- }
205
-
206
- return { data: validation.data, error: undefined };
207
- } catch (error) {
208
- return {
209
- data: undefined,
210
- error: error instanceof Error ? error : new Error(String(error)),
211
- };
225
+ if (!validation.valid) {
226
+ return { data: undefined, error: validation.error };
212
227
  }
228
+
229
+ // Handle null response
230
+ if (validation.data === null) {
231
+ return { data: null as any, error: undefined };
232
+ }
233
+
234
+ return { data: validation.data, error: undefined };
213
235
  }
214
236
 
215
237
  getRequestConfig(): { method: string; url: string; body?: any } {
@@ -224,8 +246,11 @@ export class RecordBuilder<
224
246
  // From navigated EntitySet: /sourceTable/relation('recordId')
225
247
  url = `/${this.databaseName}/${this.navigateSourceTableName}/${this.navigateRelation}('${this.recordId}')`;
226
248
  } else {
227
- // Normal record: /tableName('recordId')
228
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
249
+ // Normal record: /tableName('recordId') - use FMTID if configured
250
+ const tableId = this.occurrence
251
+ ? transformTableName(this.occurrence)
252
+ : this.tableName;
253
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
229
254
  }
230
255
 
231
256
  if (this.operation === "getSingleField" && this.operationParam) {
@@ -1,4 +1,4 @@
1
- import { BaseTable } from "./base-table";
1
+ import { BaseTable, BaseTableWithIds } from "./base-table";
2
2
 
3
3
  // Helper type to extract schema from BaseTable
4
4
  type ExtractSchema<BT> =
@@ -48,10 +48,9 @@ export class TableOccurrence<
48
48
  > {
49
49
  public readonly name: Name;
50
50
  public readonly baseTable: BT;
51
- private _navigationConfig: Nav;
51
+ protected _navigationConfig: Nav;
52
52
  public readonly navigation: ResolveNavigation<Nav>;
53
53
  public readonly defaultSelect: DefSelect;
54
-
55
54
  constructor(config: {
56
55
  readonly name: Name;
57
56
  readonly baseTable: BT;
@@ -62,7 +61,6 @@ export class TableOccurrence<
62
61
  this.baseTable = config.baseTable;
63
62
  this._navigationConfig = (config.navigation ?? {}) as Nav;
64
63
  this.defaultSelect = (config.defaultSelect ?? "schema") as DefSelect;
65
-
66
64
  // Create navigation getters that lazily resolve functions
67
65
  this.navigation = createNavigationGetters(this._navigationConfig);
68
66
  }
@@ -81,6 +79,32 @@ export class TableOccurrence<
81
79
  defaultSelect: this.defaultSelect,
82
80
  });
83
81
  }
82
+
83
+ /**
84
+ * Returns the FileMaker table occurrence ID (FMTID) if available, or the table name.
85
+ * @returns The FMTID string or the table name
86
+ */
87
+ getTableId(): string {
88
+ // Check if fmtId exists (only on TableOccurrenceWithIds)
89
+ return "fmtId" in this && (this as any).fmtId
90
+ ? (this as any).fmtId
91
+ : this.name;
92
+ }
93
+
94
+ /**
95
+ * Returns the table occurrence name.
96
+ * @returns The table name
97
+ */
98
+ getTableName(): string {
99
+ return this.name;
100
+ }
101
+
102
+ /**
103
+ * Returns true if this TableOccurrence is using FileMaker table occurrence IDs.
104
+ */
105
+ isUsingTableId(): boolean {
106
+ return "fmtId" in this && this.fmtId !== undefined;
107
+ }
84
108
  }
85
109
 
86
110
  // Helper function to create TableOccurrence with proper type inference
@@ -98,3 +122,93 @@ export function createTableOccurrence<
98
122
  }): TableOccurrence<BT, Name, {}, DefSelect> {
99
123
  return new TableOccurrence(config);
100
124
  }
125
+
126
+ // Helper type to validate that all navigation values are TableOccurrenceWithIds
127
+ type ValidateNavWithIds<Nav> =
128
+ Nav extends Record<
129
+ string,
130
+ | TableOccurrenceWithIds<any, any, any, any>
131
+ | (() => TableOccurrenceWithIds<any, any, any, any>)
132
+ >
133
+ ? Nav
134
+ : "Error: All navigation table occurrences must be TableOccurrenceWithIds when using TableOccurrenceWithIds";
135
+
136
+ /**
137
+ * TableOccurrenceWithIds extends TableOccurrence to require:
138
+ * 1. A BaseTableWithIds (which has fmfIds defined)
139
+ * 2. A required fmtId for this table occurrence
140
+ * 3. All navigation relationships must also be TableOccurrenceWithIds
141
+ *
142
+ * This ensures bidirectional type-level enforcement: if you use FileMaker IDs,
143
+ * they must be defined on both the BaseTable (fmfIds) and TableOccurrence (fmtId),
144
+ * and all related table occurrences in navigation must also have IDs.
145
+ *
146
+ * @template BT - Must be a BaseTableWithIds (enforced at type level)
147
+ * @template Name - The name of this table occurrence
148
+ * @template Nav - Navigation relationships (must all be TableOccurrenceWithIds)
149
+ * @template DefSelect - Default select behavior
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * const usersBaseWithIds = new BaseTableWithIds({
154
+ * schema: { id: z.string(), name: z.string() },
155
+ * idField: "id",
156
+ * fmfIds: { id: "FMFID:1", name: "FMFID:2" },
157
+ * });
158
+ *
159
+ * const usersTO = new TableOccurrenceWithIds({
160
+ * name: "users",
161
+ * baseTable: usersBaseWithIds,
162
+ * fmtId: "FMTID:100",
163
+ * navigation: {
164
+ * contacts: () => contactsTO, // Must also be TableOccurrenceWithIds
165
+ * },
166
+ * });
167
+ * ```
168
+ */
169
+ export class TableOccurrenceWithIds<
170
+ BT extends BaseTableWithIds<any, any, any, any> = any,
171
+ Name extends string = string,
172
+ Nav extends Record<
173
+ string,
174
+ | TableOccurrence<any, any, any, any>
175
+ | (() => TableOccurrence<any, any, any, any>)
176
+ > = {},
177
+ DefSelect extends
178
+ | "all"
179
+ | "schema"
180
+ | readonly (keyof ExtractSchema<BT>)[] = "schema",
181
+ > extends TableOccurrence<BT, Name, Nav, DefSelect> {
182
+ public readonly fmtId: `FMTID:${string}`;
183
+
184
+ constructor(config: {
185
+ readonly name: Name;
186
+ readonly baseTable: BT;
187
+ readonly fmtId: `FMTID:${string}`;
188
+ readonly navigation?: ValidateNavWithIds<Nav>;
189
+ readonly defaultSelect?: DefSelect;
190
+ }) {
191
+ super({
192
+ ...config,
193
+ navigation: config.navigation as Nav,
194
+ });
195
+ this.fmtId = config.fmtId;
196
+ }
197
+ }
198
+
199
+ // Helper function to create TableOccurrenceWithIds with proper type inference
200
+ export function createTableOccurrenceWithIds<
201
+ const Name extends string,
202
+ BT extends BaseTableWithIds<any, any, any, any>,
203
+ DefSelect extends
204
+ | "all"
205
+ | "schema"
206
+ | readonly (keyof ExtractSchema<BT>)[] = "schema",
207
+ >(config: {
208
+ name: Name;
209
+ baseTable: BT;
210
+ fmtId: `FMTID:${string}`;
211
+ defaultSelect?: DefSelect;
212
+ }): TableOccurrenceWithIds<BT, Name, {}, DefSelect> {
213
+ return new TableOccurrenceWithIds(config);
214
+ }
@@ -8,6 +8,10 @@ import type { TableOccurrence } from "./table-occurrence";
8
8
  import type { BaseTable } from "./base-table";
9
9
  import { QueryBuilder } from "./query-builder";
10
10
  import { type FFetchOptions } from "@fetchkit/ffetch";
11
+ import {
12
+ transformFieldNamesToIds,
13
+ transformTableName,
14
+ } from "../transform";
11
15
 
12
16
  /**
13
17
  * Initial update builder returned from EntitySet.update(data)
@@ -134,79 +138,103 @@ export class ExecutableUpdateBuilder<
134
138
  async execute(
135
139
  options?: RequestInit & FFetchOptions,
136
140
  ): Promise<Result<{ updatedCount: number }>> {
137
- try {
138
- let url: string;
139
-
140
- if (this.mode === "byId") {
141
- // Update single record by ID: PATCH /{database}/{table}('id')
142
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
143
- } else {
144
- // Update by filter: PATCH /{database}/{table}?$filter=...
145
- if (!this.queryBuilder) {
146
- throw new Error("Query builder is required for filter-based update");
147
- }
148
-
149
- // Get the query string from the configured QueryBuilder
150
- const queryString = this.queryBuilder.getQueryString();
151
- // Remove the leading "/" from the query string as we'll build our own URL
152
- const queryParams = queryString.startsWith(`/${this.tableName}`)
141
+ // Transform table name to FMTID if using entity IDs
142
+ const tableId = this.occurrence
143
+ ? transformTableName(this.occurrence)
144
+ : this.tableName;
145
+
146
+ // Transform field names to FMFIDs if using entity IDs
147
+ const transformedData = this.occurrence?.baseTable
148
+ ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
149
+ : this.data;
150
+
151
+ let url: string;
152
+
153
+ if (this.mode === "byId") {
154
+ // Update single record by ID: PATCH /{database}/{table}('id')
155
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
156
+ } else {
157
+ // Update by filter: PATCH /{database}/{table}?$filter=...
158
+ if (!this.queryBuilder) {
159
+ throw new Error("Query builder is required for filter-based update");
160
+ }
161
+
162
+ // Get the query string from the configured QueryBuilder
163
+ const queryString = this.queryBuilder.getQueryString();
164
+ // The query string will have the tableId already transformed by QueryBuilder
165
+ // Remove the leading "/" and table name from the query string as we'll build our own URL
166
+ const queryParams = queryString.startsWith(`/${tableId}`)
167
+ ? queryString.slice(`/${tableId}`.length)
168
+ : queryString.startsWith(`/${this.tableName}`)
153
169
  ? queryString.slice(`/${this.tableName}`.length)
154
170
  : queryString;
155
171
 
156
- url = `/${this.databaseName}/${this.tableName}${queryParams}`;
157
- }
172
+ url = `/${this.databaseName}/${tableId}${queryParams}`;
173
+ }
158
174
 
159
- // Make PATCH request with JSON body
160
- const response = await this.context._makeRequest(url, {
161
- method: "PATCH",
162
- headers: {
163
- "Content-Type": "application/json",
164
- },
165
- body: JSON.stringify(this.data),
166
- ...options,
167
- });
168
-
169
- // Both byId and byFilter return affected row count
170
- let updatedCount = 0;
171
-
172
- if (typeof response === "number") {
173
- updatedCount = response;
174
- } else if (response && typeof response === "object") {
175
- // Check if the response has a count property (fallback)
176
- updatedCount = (response as any).updatedCount || 0;
177
- }
175
+ // Make PATCH request with JSON body
176
+ const result = await this.context._makeRequest(url, {
177
+ method: "PATCH",
178
+ headers: {
179
+ "Content-Type": "application/json",
180
+ },
181
+ body: JSON.stringify(transformedData),
182
+ ...options,
183
+ });
184
+
185
+ if (result.error) {
186
+ return { data: undefined, error: result.error };
187
+ }
188
+
189
+ const response = result.data;
178
190
 
179
- return { data: { updatedCount }, error: undefined };
180
- } catch (error) {
181
- return {
182
- data: undefined,
183
- error: error instanceof Error ? error : new Error(String(error)),
184
- };
191
+ // Both byId and byFilter return affected row count
192
+ let updatedCount = 0;
193
+
194
+ if (typeof response === "number") {
195
+ updatedCount = response;
196
+ } else if (response && typeof response === "object") {
197
+ // Check if the response has a count property (fallback)
198
+ updatedCount = (response as any).updatedCount || 0;
185
199
  }
200
+
201
+ return { data: { updatedCount }, error: undefined };
186
202
  }
187
203
 
188
204
  getRequestConfig(): { method: string; url: string; body?: any } {
205
+ // Transform table name to FMTID if using entity IDs
206
+ const tableId = this.occurrence
207
+ ? transformTableName(this.occurrence)
208
+ : this.tableName;
209
+
210
+ // Transform field names to FMFIDs if using entity IDs
211
+ const transformedData = this.occurrence?.baseTable
212
+ ? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
213
+ : this.data;
214
+
189
215
  let url: string;
190
216
 
191
217
  if (this.mode === "byId") {
192
- url = `/${this.databaseName}/${this.tableName}('${this.recordId}')`;
218
+ url = `/${this.databaseName}/${tableId}('${this.recordId}')`;
193
219
  } else {
194
220
  if (!this.queryBuilder) {
195
221
  throw new Error("Query builder is required for filter-based update");
196
222
  }
197
223
 
198
224
  const queryString = this.queryBuilder.getQueryString();
199
- const queryParams = queryString.startsWith(`/${this.tableName}`)
200
- ? queryString.slice(`/${this.tableName}`.length)
201
- : queryString;
225
+ const queryParams = queryString.startsWith(`/${tableId}`)
226
+ ? queryString.slice(`/${tableId}`.length)
227
+ : queryString.startsWith(`/${this.tableName}`)
228
+ ? queryString.slice(`/${this.tableName}`.length)
229
+ : queryString;
202
230
 
203
- url = `/${this.databaseName}/${this.tableName}${queryParams}`;
231
+ url = `/${this.databaseName}/${tableId}${queryParams}`;
204
232
  }
205
233
 
206
234
  return {
207
235
  method: "PATCH",
208
236
  url,
209
- body: JSON.stringify(this.data),
237
+ body: JSON.stringify(transformedData),
210
238
  };
211
239
  }
212
240
  }