diff --git a/.changeset/get-query-string-entity-ids-override.md b/.changeset/get-query-string-entity-ids-override.md new file mode 100644 index 00000000..3f64863e --- /dev/null +++ b/.changeset/get-query-string-entity-ids-override.md @@ -0,0 +1,6 @@ +--- +"@proofkit/fmodata": patch +--- + +Add `useEntityIds` override parameter to `getQueryString()` methods in QueryBuilder and RecordBuilder, allowing users to override entity ID usage when inspecting query strings without executing requests. + diff --git a/apps/docs/components.json b/apps/docs/components.json index 49c6a2cd..4adbc026 100644 --- a/apps/docs/components.json +++ b/apps/docs/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" -} \ No newline at end of file + "registries": { + "@reui": "https://reui.io/r/{name}.json" + } +} diff --git a/apps/docs/content/docs/fmodata/batch.mdx b/apps/docs/content/docs/fmodata/batch.mdx new file mode 100644 index 00000000..199850c0 --- /dev/null +++ b/apps/docs/content/docs/fmodata/batch.mdx @@ -0,0 +1,171 @@ +--- +title: Batch Operations +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + +Batch operations allow you to execute multiple queries and operations together in a single request. All operations in a batch are executed atomically - they all succeed or all fail together. This is both more efficient (fewer network round-trips) and ensures data consistency across related operations. + +## Batch Result Structure + +Batch operations return a `BatchResult` object that contains individual results for each operation. Each result has its own `data`, `error`, and `status` properties, allowing you to handle success and failure on a per-operation basis: + +```typescript +type BatchItemResult = { + data: T | undefined; + error: FMODataErrorType | undefined; + status: number; // HTTP status code (0 for truncated operations) +}; + +type BatchResult = { + results: { [K in keyof T]: BatchItemResult }; + successCount: number; + errorCount: number; + truncated: boolean; // true if FileMaker stopped processing due to an error + firstErrorIndex: number | null; // Index of the first operation that failed +}; +``` + +## Basic Batch with Multiple Queries + +Execute multiple read operations in a single batch: + +```typescript +// Create query builders +const contactsQuery = db.from(contacts).list().top(5); +const usersQuery = db.from(users).list().top(5); + +// Execute both queries in a single batch +const result = await db.batch([contactsQuery, usersQuery]).execute(); + +// Access individual results +const [r1, r2] = result.results; + +if (r1.error) { + console.error("Contacts query failed:", r1.error); +} else { + console.log("Contacts:", r1.data); +} + +if (r2.error) { + console.error("Users query failed:", r2.error); +} else { + console.log("Users:", r2.data); +} + +// Check summary statistics +console.log(`Success: ${result.successCount}, Errors: ${result.errorCount}`); +``` + +## Mixed Operations (Reads and Writes) + +Combine queries, inserts, updates, and deletes in a single batch: + +```typescript +// Mix different operation types +const listQuery = db.from(contacts).list().top(10); +const insertOp = db.from(contacts).insert({ + name: "John Doe", + email: "john@example.com", +}); +const updateOp = db.from(users).update({ active: true }).byId("user-123"); + +// All operations execute atomically +const result = await db.batch([listQuery, insertOp, updateOp]).execute(); + +// Access individual results +const [r1, r2, r3] = result.results; + +if (r1.error) { + console.error("List query failed:", r1.error); +} else { + console.log("Fetched contacts:", r1.data); +} + +if (r2.error) { + console.error("Insert failed:", r2.error); +} else { + console.log("Inserted contact:", r2.data); +} + +if (r3.error) { + console.error("Update failed:", r3.error); +} else { + console.log("Updated user:", r3.data); +} +``` + +## Handling Errors in Batches + +When FileMaker encounters an error in a batch operation, it **stops processing** subsequent operations. Operations that were never executed due to an earlier error will have a `BatchTruncatedError`: + +```typescript +import { BatchTruncatedError, isBatchTruncatedError } from "@proofkit/fmodata"; + +const result = await db.batch([query1, query2, query3]).execute(); + +const [r1, r2, r3] = result.results; + +// First operation succeeded +if (r1.error) { + console.error("First query failed:", r1.error); +} else { + console.log("First query succeeded:", r1.data); +} + +// Second operation failed +if (r2.error) { + console.error("Second query failed:", r2.error); + console.log("HTTP Status:", r2.status); // e.g., 404 +} + +// Third operation was never executed (truncated) +if (r3.error && isBatchTruncatedError(r3.error)) { + console.log("Third operation was not executed"); + console.log(`Failed at operation ${r3.error.failedAtIndex}`); + console.log(`This operation index: ${r3.error.operationIndex}`); + console.log("Status:", r3.status); // 0 (never executed) +} + +// Check if batch was truncated +if (result.truncated) { + console.log(`Batch stopped early at index ${result.firstErrorIndex}`); +} +``` + +## Transactional Behavior + +Batch operations are transactional for write operations (inserts, updates, deletes). If any operation in the batch fails, all write operations are rolled back: + +```typescript +const result = await db + .batch([ + db.from(users).insert({ username: "alice", email: "alice@example.com" }), + db.from(users).insert({ username: "bob", email: "bob@example.com" }), + db.from(users).insert({ username: "charlie", email: "invalid" }), // This fails + ]) + .execute(); + +// Check individual results +const [r1, r2, r3] = result.results; + +if (r1.error || r2.error || r3.error) { + // All three inserts are rolled back - no users were created + console.error("Batch had errors:"); + if (r1.error) console.error("Operation 1:", r1.error); + if (r2.error) console.error("Operation 2:", r2.error); + if (r3.error) console.error("Operation 3:", r3.error); +} +``` + +## Important Notes + +- **FileMaker stops on first error**: When an error occurs, FileMaker stops processing subsequent operations in the batch. Truncated operations will have `BatchTruncatedError` with `status: 0`. +- **Insert operations in batches**: FileMaker ignores `Prefer: return=representation` in batch requests. Insert operations return `{}` or `{ ROWID?: number }` instead of the full created record. +- **All results are always defined**: Every operation in the batch will have a corresponding result in `result.results`, even if it was never executed (truncated operations). +- **Summary statistics**: Use `result.successCount`, `result.errorCount`, `result.truncated`, and `result.firstErrorIndex` for quick batch status checks. + + +Batch operations automatically group write operations (POST, PATCH, DELETE) into changesets for transactional behavior, while read operations (GET) are executed individually within the batch. + diff --git a/apps/docs/content/docs/fmodata/crud.mdx b/apps/docs/content/docs/fmodata/crud.mdx new file mode 100644 index 00000000..3af5096e --- /dev/null +++ b/apps/docs/content/docs/fmodata/crud.mdx @@ -0,0 +1,152 @@ +--- +title: Modifying Data +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + +## Insert + +Insert new records with type-safe data: + +```typescript +// Insert a new user +const result = await db + .from(users) + .insert({ + username: "johndoe", + email: "john@example.com", + active: true, + }) + .execute(); + +if (result.data) { + console.log("Created user:", result.data); +} +``` + +Fields are automatically required for insert if they use `.notNull()`. Read-only fields (including primary keys) are automatically excluded: + +```typescript +const users = fmTableOccurrence("users", { + id: textField().primaryKey(), // Auto-required, but excluded from insert (primaryKey) + username: textField().notNull(), // Auto-required (notNull) + email: textField().notNull(), // Auto-required (notNull) + phone: textField(), // Optional by default (nullable) + createdAt: timestampField().readOnly(), // Excluded from insert/update +}); + +// TypeScript enforces: username and email are required +// TypeScript excludes: id and createdAt cannot be provided +const result = await db + .from(users) + .insert({ + username: "johndoe", + email: "john@example.com", + phone: "+1234567890", // Optional + }) + .execute(); +``` + +## Update + +Update records by ID or filter: + +```typescript +// Update by ID +const result = await db + .from(users) + .update({ username: "newname" }) + .byId("user-123") + .execute(); + +if (result.data) { + console.log(`Updated ${result.data.updatedCount} record(s)`); +} + +// Update by filter (using ORM API) +import { lt, and, eq } from "@proofkit/fmodata"; + +const result = await db + .from(users) + .update({ active: false }) + .where(lt(users.lastLogin, "2023-01-01")) + .execute(); + +// Complex filter example +const result = await db + .from(users) + .update({ active: false }) + .where(and(eq(users.active, true), lt(users.count, 5))) + .execute(); +``` + + +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. + + +## Delete + +Delete records by ID or filter: + +```typescript +// Delete by ID +const result = await db.from(users).delete().byId("user-123").execute(); + +if (result.data) { + console.log(`Deleted ${result.data.deletedCount} record(s)`); +} + +// Delete by filter (using ORM API) +import { eq, and, lt } from "@proofkit/fmodata"; + +const result = await db + .from(users) + .delete() + .where(eq(users.active, false)) + .execute(); + +// Delete with complex filters +const result = await db + .from(users) + .delete() + .where(and(eq(users.active, false), lt(users.lastLogin, "2023-01-01"))) + .execute(); +``` + +## Required and Read-Only Fields + +The library automatically infers which fields are required based on field builder configuration: + +- **Auto-inference:** Fields with `.notNull()` are automatically required for insert +- **Primary keys:** Fields with `.primaryKey()` are automatically read-only +- **Read-only fields:** Use `.readOnly()` to exclude fields from insert/update (e.g., timestamps, calculated fields) +- **Update flexibility:** All fields are optional for updates (except read-only fields) + +```typescript +const users = fmTableOccurrence("users", { + id: textField().primaryKey(), // Auto-required, auto-readOnly (primaryKey) + username: textField().notNull(), // Auto-required (notNull) + email: textField().notNull(), // Auto-required (notNull) + status: textField(), // Optional (nullable by default) + createdAt: timestampField().readOnly(), // Read-only system field + updatedAt: timestampField(), // Optional (nullable) +}); + +// Insert: username and email are required +// Insert: id and createdAt are excluded (cannot be provided - read-only) +db.from(users).insert({ + username: "john", + email: "john@example.com", + status: "active", // Optional + updatedAt: new Date().toISOString(), // Optional +}); + +// Update: all fields are optional except id and createdAt are excluded +db.from(users) + .update({ + status: "active", // Optional + // id and createdAt cannot be modified (read-only) + }) + .byId("user-123"); +``` diff --git a/apps/docs/content/docs/fmodata/custom-fetch-handlers.mdx b/apps/docs/content/docs/fmodata/custom-fetch-handlers.mdx new file mode 100644 index 00000000..d082b2bc --- /dev/null +++ b/apps/docs/content/docs/fmodata/custom-fetch-handlers.mdx @@ -0,0 +1,16 @@ +--- +title: Custom Fetch Handlers +description: You can provide custom fetch handlers for testing or custom networking +--- + +```typescript +const customFetch = async (url, options) => { + console.log("Fetching:", url); + return fetch(url, options); +}; + +const result = await db.from("users").list().execute({ + fetchHandler: customFetch, +}); +``` + diff --git a/apps/docs/content/docs/fmodata/entity-ids.mdx b/apps/docs/content/docs/fmodata/entity-ids.mdx new file mode 100644 index 00000000..7000b722 --- /dev/null +++ b/apps/docs/content/docs/fmodata/entity-ids.mdx @@ -0,0 +1,104 @@ +--- +title: Entity IDs vs. Field Names +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + + +This library supports using FileMaker's internal field identifiers (FMFID) and table occurrence identifiers (FMTID) instead of names. This protects your integration from both field and table occurrence name changes. + +## Why use Entity IDs? + +There are some pros and cons of this, so it's important to understand how this works behind the scenes so you can make the best decision for your use case. + +**Pros**: +- ✅ Entity IDs are a persistent internal identifier that doesn't change when a field's name does, which can make your API calls more resilient. +- ✅ Since each OData request is logged and query requests include your full search criteria, using entity IDs help obscure the logs to make it less obvious what your actual table and fields names are. + +**Cons**: +- ⚠️ Harder to debug queries with obfuscated field/table names +- ⚠️ IDs are scoped to the FileMaker file and therefore won't work if you want to make the same query on a different file. + - 📝 Note: If you are using OttoFMS to deploy files to multiple servers, entity IDs can be relied upon as long as you have strict practice of only deploying from a central development copy and never re-creating schema in each file. + + +## How it works + +There are 2 steps to enable this feature: +1. Define the entity IDs in your schema +2. Enable `useEntityIds` in for your request. This can be set at the database, level, schema definition, or request level. + +Once enabled, this feature will feel transparent to you. Behind the scenes the library will transform the names to entity IDs in your request, and the response back to the names you specify in your schema. + +### Step 1: Define Entity IDs in your schema +Define your schema with entity IDs using the `.entityId()` method on field builders and the `entityId` option in `fmTableOccurrence()`. + + +This step is done for you automatically when you use the `@proofkit/typegen` tool to generate your schema. + +Otherwise, you can find them manually in the XML version of the `$metadata` endpoint for your database, or you can calculate them using these [custom functions](https://github.com/rwu2359/CFforID) from John Renfrew. + + +```typescript title="Example schema with entity IDs" +import { + fmTableOccurrence, + textField, + timestampField, +} from "@proofkit/fmodata"; + +// Define a table with FileMaker field IDs and table occurrence ID +const users = fmTableOccurrence( + "users", + { + id: textField().primaryKey().entityId("FMFID:12039485"), + username: textField().notNull().entityId("FMFID:34323433"), + email: textField().entityId("FMFID:12232424"), + createdAt: timestampField().readOnly().entityId("FMFID:43234355"), + }, + { + entityId: "FMTID:12432533", // FileMaker table occurrence ID + }, +); +``` + +### Step 2: Enable `useEntityIds` for your request(s) +Enable `useEntityIds` in for your request. This can be set at the database, level, schema definition, or request level. + +```typescript tab="Database Level" +// Enable for all requests to this database +const db = connection.database("MyDatabase", { + useEntityIds: true, +}); +``` + +```typescript tab="Schema Level" +// Enable for all requests to this schema +const users = fmTableOccurrence( + "users", + { + id: textField().primaryKey().entityId("FMFID:12039485"), + username: textField().notNull().entityId("FMFID:34323433"), + email: textField().entityId("FMFID:12232424"), + createdAt: timestampField().readOnly().entityId("FMFID:43234355"), + }, + { + entityId: "FMTID:12432533", // FileMaker table occurrence ID + useEntityIds: true, + }, +); +``` + +```typescript tab="Request Level" +// Enable for this request only +const { data, error } = await db.from(users).list().execute({ + useEntityIds: true, +}); +``` + +The heirarchy is Database > Schema > Request. This means that if you enable at the database level, you can turn it off at the schema level, or request level. + +To help you with debugging, you can also set `useEntityIds` in the `getQueryString()` method to inspect the query string with or without entity IDs. +```typescript +const queryString = db.from(users).list().getQueryString({ useEntityIds: false }); +console.log(queryString); // e.g. "/users?$select=id,username,email,createdAt" +``` \ No newline at end of file diff --git a/apps/docs/content/docs/fmodata/errors.mdx b/apps/docs/content/docs/fmodata/errors.mdx new file mode 100644 index 00000000..bbe626cc --- /dev/null +++ b/apps/docs/content/docs/fmodata/errors.mdx @@ -0,0 +1,272 @@ +--- +title: Error Handling +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + +All operations return a `Result` type with either `data` or `error`. The library provides rich error types that help you handle different error scenarios appropriately. + +## Basic Error Checking + +```typescript +const result = await db.from(users).list().execute(); + +if (result.error) { + console.error("Query failed:", result.error.message); + return; +} + +if (result.data) { + console.log("Query succeeded:", result.data); +} +``` + +## HTTP Errors + +Handle HTTP status codes (4xx, 5xx) with the `HTTPError` class: + +```typescript +import { HTTPError, isHTTPError } from "@proofkit/fmodata"; + +const result = await db.from(users).list().execute(); + +if (result.error) { + if (isHTTPError(result.error)) { + // TypeScript knows this is HTTPError + console.log("HTTP Status:", result.error.status); + + if (result.error.isNotFound()) { + console.log("Resource not found"); + } else if (result.error.isUnauthorized()) { + console.log("Authentication required"); + } else if (result.error.is5xx()) { + console.log("Server error - try again later"); + } else if (result.error.is4xx()) { + console.log("Client error:", result.error.statusText); + } + + // Access the response body if available + if (result.error.response) { + console.log("Error details:", result.error.response); + } + } +} +``` + +## Network Errors + +Handle network-level errors (timeouts, connection issues, etc.): + +```typescript +import { + TimeoutError, + NetworkError, + RetryLimitError, + CircuitOpenError, +} from "@proofkit/fmodata"; + +const result = await db.from(users).list().execute(); + +if (result.error) { + if (result.error instanceof TimeoutError) { + console.log("Request timed out"); + // Show user-friendly timeout message + } else if (result.error instanceof NetworkError) { + console.log("Network connectivity issue"); + // Show offline message + } else if (result.error instanceof RetryLimitError) { + console.log("Request failed after retries"); + // Log the underlying error: result.error.cause + } else if (result.error instanceof CircuitOpenError) { + console.log("Service is currently unavailable"); + // Show maintenance message + } +} +``` + +## Validation Errors + +When schema validation fails, you get a `ValidationError` with rich context: + +```typescript +import { ValidationError, isValidationError } from "@proofkit/fmodata"; + +const result = await db.from(users).list().execute(); + +if (result.error) { + if (isValidationError(result.error)) { + // Access validation issues (Standard Schema format) + console.log("Validation failed for field:", result.error.field); + console.log("Issues:", result.error.issues); + console.log("Failed value:", result.error.value); + } +} +``` + +### Validator-Agnostic Error Handling + +The library uses [Standard Schema](https://github.com/standard-schema/standard-schema) to support any validation library (Zod, Valibot, ArkType, etc.). The `ValidationError.cause` property contains the normalized Standard Schema issues array: + +```typescript +import { ValidationError } from "@proofkit/fmodata"; + +const result = await db.from(users).list().execute(); + +if (result.error instanceof ValidationError) { + // The cause property (ES2022 Error.cause) contains the Standard Schema issues array + // This is validator-agnostic and works with Zod, Valibot, ArkType, etc. + console.log("Validation issues:", result.error.cause); + console.log("Issues are also available directly:", result.error.issues); + + // Both point to the same array + console.log(result.error.cause === result.error.issues); // true + + // Access additional context + console.log("Failed field:", result.error.field); + console.log("Failed value:", result.error.value); + + // Standard Schema issues have a normalized format + result.error.issues.forEach((issue) => { + console.log("Path:", issue.path); + console.log("Message:", issue.message); + }); +} +``` + +## OData Errors + +Handle OData-specific protocol errors: + +```typescript +import { ODataError, isODataError } from "@proofkit/fmodata"; + +const result = await db.from(users).list().execute(); + +if (result.error) { + if (isODataError(result.error)) { + console.log("OData Error Code:", result.error.code); + console.log("OData Error Message:", result.error.message); + console.log("OData Error Details:", result.error.details); + } +} +``` + +## Error Handling Patterns + +### Pattern 1: Using instanceof + +```typescript +import { + HTTPError, + ValidationError, + TimeoutError, + NetworkError, +} from "@proofkit/fmodata"; + +const result = await db.from(users).list().execute(); + +if (result.error) { + if (result.error instanceof TimeoutError) { + showTimeoutMessage(); + } else if (result.error instanceof HTTPError) { + if (result.error.isNotFound()) { + showNotFoundMessage(); + } else if (result.error.is5xx()) { + showServerErrorMessage(); + } + } else if (result.error instanceof ValidationError) { + showValidationError(result.error.field, result.error.issues); + } else if (result.error instanceof NetworkError) { + showOfflineMessage(); + } +} +``` + +### Pattern 2: Using kind property (for exhaustive matching) + +```typescript +const result = await db.from(users).list().execute(); + +if (result.error) { + switch (result.error.kind) { + case "TimeoutError": + showTimeoutMessage(); + break; + case "HTTPError": + handleHTTPError(result.error.status); + break; + case "ValidationError": + showValidationError(result.error.field, result.error.issues); + break; + case "NetworkError": + showOfflineMessage(); + break; + case "ODataError": + handleODataError(result.error.code); + break; + // TypeScript ensures exhaustive matching! + } +} +``` + +### Pattern 3: Using type guards + +```typescript +import { + isHTTPError, + isValidationError, + isODataError, + isNetworkError, +} from "@proofkit/fmodata"; + +const result = await db.from("users").list().execute(); + +if (result.error) { + if (isHTTPError(result.error)) { + // TypeScript knows this is HTTPError + console.log("Status:", result.error.status); + } else if (isValidationError(result.error)) { + // TypeScript knows this is ValidationError + console.log("Field:", result.error.field); + console.log("Issues:", result.error.issues); + } else if (isODataError(result.error)) { + // TypeScript knows this is ODataError + console.log("Code:", result.error.code); + } else if (isNetworkError(result.error)) { + // TypeScript knows this is NetworkError + console.log("Network issue:", result.error.cause); + } +} +``` + +## Error Properties + +All errors include helpful metadata: + +```typescript +if (result.error) { + // All errors have a timestamp + console.log("Error occurred at:", result.error.timestamp); + + // All errors have a kind property for discriminated unions + console.log("Error kind:", result.error.kind); + + // All errors have a message + console.log("Error message:", result.error.message); +} +``` + +## Available Error Types + +- **`HTTPError`** - HTTP status errors (4xx, 5xx) with helper methods (`is4xx()`, `is5xx()`, `isNotFound()`, etc.) +- **`ODataError`** - OData protocol errors with code and details +- **`ValidationError`** - Schema validation failures with issues, schema reference, and failed value +- **`ResponseStructureError`** - Malformed API responses +- **`RecordCountMismatchError`** - When `single()` or `maybeSingle()` expectations aren't met +- **`TimeoutError`** - Request timeout (from ffetch) +- **`NetworkError`** - Network connectivity issues (from ffetch) +- **`RetryLimitError`** - Request failed after retries (from ffetch) +- **`CircuitOpenError`** - Circuit breaker is open (from ffetch) +- **`AbortError`** - Request was aborted (from ffetch) +- **`BatchTruncatedError`** - Batch operation was truncated due to an earlier error diff --git a/apps/docs/content/docs/fmodata/extra-properties.mdx b/apps/docs/content/docs/fmodata/extra-properties.mdx new file mode 100644 index 00000000..03f9ca62 --- /dev/null +++ b/apps/docs/content/docs/fmodata/extra-properties.mdx @@ -0,0 +1,52 @@ +--- +title: Extra Properties +description: Control which extra properties are included in the response +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +## Include Special Columns by Default (ROWID and ROWMODID) + +FileMaker provides special columns `ROWID` and `ROWMODID` that uniquely identify records and track modifications. These can be included in query responses when enabled. + +Enable special columns at the database level: + +```typescript +const db = connection.database("MyDatabase", { + includeSpecialColumns: true, +}); + +const result = await db.from(users).list().execute(); +// result.data[0] will have ROWID and ROWMODID properties +``` + +Override at the request level: + +```typescript +// Enable for this request only +const result = await db.from(users).list().execute({ + includeSpecialColumns: true, +}); + +// Disable for this request +const result = await db.from(users).list().execute({ + includeSpecialColumns: false, +}); +``` + + +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. + + + + + +## OData Annotations + +By default, the library automatically strips OData annotations fields (`@id` and `@editLink`) from responses. If you need these fields, you can include them by passing `includeODataAnnotations: true`: + +```typescript +const result = await db.from("users").list().execute({ + includeODataAnnotations: true, +}); +``` diff --git a/apps/docs/content/docs/fmodata/index.mdx b/apps/docs/content/docs/fmodata/index.mdx index 16fb1479..13c05d2e 100644 --- a/apps/docs/content/docs/fmodata/index.mdx +++ b/apps/docs/content/docs/fmodata/index.mdx @@ -1,6 +1,44 @@ --- -title: FileMaker OData API Library +title: Overview description: "@proofkit/fmodata" --- -FileMaker OData API Library +import { Callout } from "fumadocs-ui/components/callout"; +import { Card, Cards } from "fumadocs-ui/components/card"; + + +This library is in beta status. We don't expect the method names or arguments to change further, but please submit feedback or report issues on the [community forum](https://community.ottomatic.cloud/c/proofkit/13) or on [GitHub](https://github.com/proofgeist/proofkit/issues). + + +A strongly-typed FileMaker OData API client that provides full TypeScript type inference, runtime validation, and a fluent query builder API. + +## Key Features + +- **Type-safe queries** - Full TypeScript inference for field names and types +- **ORM-style API** - Fluent builder pattern with column references +- **Runtime validation** - Optional standard schema validation for data transformation +- **Relationship navigation** - Type-safe navigation and expansion of related records +- **Batch operations** - Execute multiple operations atomically +- **Schema management** - Create and modify tables and fields programmatically +- **Entity IDs** - Protect against field/table name changes using FileMaker IDs +- **Webhooks** - Easily manage webhooks on your FileMaker Server to monitor table changes + +## Prerequisites + +To use this library you need: + +- OData service enabled on your FileMaker server +- A FileMaker account with `fmodata` privilege enabled +- (if using OttoFMS) a Data API key setup for your FileMaker account with OData enabled + + +## When to Use OData vs Data API + +Claris has given signals that OData is the future of data access for FileMaker. It's much faster and more powerful than the Data API since it does not require the server to spin up a server-side client or maintain a session for each request. However, it's a new paradigm for thinking about how we can interact with the FileMaker server and may take some adjustment. You should not worry about continuing to use the Data API if needed, but we suggest trying OData for new projects. + + + Get up and running in minutes + Learn how to define your table schemas + Filter, sort, and paginate your queries + Create, read, update, and delete records + diff --git a/apps/docs/content/docs/fmodata/inspecting-queries.mdx b/apps/docs/content/docs/fmodata/inspecting-queries.mdx new file mode 100644 index 00000000..f5199b9b --- /dev/null +++ b/apps/docs/content/docs/fmodata/inspecting-queries.mdx @@ -0,0 +1,20 @@ +--- +title: Inspecting Query Strings +description: View the full query string without executing the request +--- + +To help you with debugging, you can inspect the full query string without executing the request using the `getQueryString()` method. + +```typescript +const queryString = db + .from("users") + .list() + .select({ username: users.username, email: users.email }) + .where(eq(users.active, true)) + .orderBy(asc(users.username)) + .top(10) + .getQueryString(); + +console.log(queryString); +// Output: "/users?$select=username,email&$filter=active eq true&$orderby=username&$top=10" +``` \ No newline at end of file diff --git a/apps/docs/content/docs/fmodata/meta.json b/apps/docs/content/docs/fmodata/meta.json index b3470945..eb29546d 100644 --- a/apps/docs/content/docs/fmodata/meta.json +++ b/apps/docs/content/docs/fmodata/meta.json @@ -2,5 +2,27 @@ "title": "FileMaker OData API", "description": "@proofkit/fmodata", "icon": "Database", + "pages": [ + "index", + "---Setup---", + "quick-start", + "schema", + "---Guides---", + "queries", + "crud", + "relationships", + "scripts", + "webhooks", + "batch", + "schema-management", + "---Advanced Features---", + "inspecting-queries", + "entity-ids", + "extra-properties", + "custom-fetch-handlers", + "---Reference---", + "errors", + "methods" + ], "root": true } diff --git a/apps/docs/content/docs/fmodata/methods.mdx b/apps/docs/content/docs/fmodata/methods.mdx new file mode 100644 index 00000000..3654f081 --- /dev/null +++ b/apps/docs/content/docs/fmodata/methods.mdx @@ -0,0 +1,147 @@ +--- +title: API Reference +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + +Quick reference for all available methods and operators in `@proofkit/fmodata`. + +## Query Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `list()` | Retrieve multiple records | `db.from(users).list().execute()` | +| `get(id)` | Get a single record by ID | `db.from(users).get("user-123").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()` | + +## CRUD Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `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()` | + +## Query Modifiers + +| Method | Description | Example | +|--------|-------------|---------| +| `where(filter)` | Filter records | `db.from(users).list().where(eq(users.active, true)).execute()` | +| `select(fields)` | Select specific fields | `db.from(users).list().select({ username: users.username }).execute()` | +| `orderBy(...columns)` | Sort results | `db.from(users).list().orderBy(asc(users.name)).execute()` | +| `top(n)` | Limit results | `db.from(users).list().top(10).execute()` | +| `skip(n)` | Skip records (pagination) | `db.from(users).list().top(10).skip(20).execute()` | +| `count()` | Get total count | `db.from(users).list().count().execute()` | +| `expand(table, builder?)` | Expand related records | `db.from(contacts).list().expand(users).execute()` | +| `navigate(table)` | Navigate to related table | `db.from(contacts).get("id").navigate(users).execute()` | + +## Filter Operators + +### Comparison Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `eq(column, value)` | Equal to | `eq(users.active, true)` | +| `ne(column, value)` | Not equal to | `ne(users.role, "admin")` | +| `gt(column, value)` | Greater than | `gt(users.age, 18)` | +| `gte(column, value)` | Greater than or equal | `gte(users.score, 100)` | +| `lt(column, value)` | Less than | `lt(users.age, 65)` | +| `lte(column, value)` | Less than or equal | `lte(users.score, 0)` | + +### String Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `contains(column, value)` | Contains substring | `contains(users.name, "John")` | +| `startsWith(column, value)` | Starts with | `startsWith(users.email, "admin")` | +| `endsWith(column, value)` | Ends with | `endsWith(users.email, ".com")` | + +### Array Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `inArray(column, values)` | Value in array | `inArray(users.role, ["admin", "moderator"])` | +| `notInArray(column, values)` | Value not in array | `notInArray(users.status, ["banned", "deleted"])` | + +### Null Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `isNull(column)` | Is null | `isNull(users.deletedAt)` | +| `isNotNull(column)` | Is not null | `isNotNull(users.email)` | + +### Logical Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `and(...filters)` | Logical AND | `and(eq(users.active, true), gt(users.age, 18))` | +| `or(...filters)` | Logical OR | `or(eq(users.role, "admin"), eq(users.role, "moderator"))` | +| `not(filter)` | Logical NOT | `not(eq(users.active, false))` | + +## Sort Helpers + +| Helper | Description | Example | +|--------|-------------|---------| +| `asc(column)` | Ascending order | `orderBy(asc(users.name))` | +| `desc(column)` | Descending order | `orderBy(desc(users.age))` | + +## Webhook Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `webhook.add(config)` | Add a webhook | `db.webhook.add({ webhook: "https://...", tableName: users })` | +| `webhook.list()` | List all webhooks | `db.webhook.list()` | +| `webhook.get(id)` | Get a webhook by ID | `db.webhook.get(1)` | +| `webhook.remove(id)` | Remove a webhook | `db.webhook.remove(1)` | +| `webhook.invoke(id, options?)` | Manually invoke a webhook | `db.webhook.invoke(1, { rowIDs: [1, 2, 3] })` | + +## Schema Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `schema.createTable(name, fields)` | Create a new table | `db.schema.createTable("users", fields)` | +| `schema.addFields(table, fields)` | Add fields to a table | `db.schema.addFields("users", newFields)` | +| `schema.deleteTable(name)` | Delete a table | `db.schema.deleteTable("old_table")` | +| `schema.deleteField(table, field)` | Delete a field | `db.schema.deleteField("users", "old_field")` | +| `schema.createIndex(table, field)` | Create an index | `db.schema.createIndex("users", "email")` | +| `schema.deleteIndex(table, field)` | Delete an index | `db.schema.deleteIndex("users", "email")` | + +## Script Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `runScript(name, options?)` | Execute a FileMaker script | `db.runScript("MyScript", { scriptParam: "value" })` | + +## Batch Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `batch(operations)` | Execute multiple operations | `db.batch([query1, query2]).execute()` | + +## Field Builders + +| Builder | FileMaker Type | Chainable Methods | +|---------|---------------|-------------------| +| `textField()` | Text | `.primaryKey()`, `.notNull()`, `.readOnly()`, `.entityId()`, `.readValidator()`, `.writeValidator()` | +| `numberField()` | Number | Same as above | +| `dateField()` | Date | Same as above | +| `timeField()` | Time | Same as above | +| `timestampField()` | Timestamp | Same as above | +| `containerField()` | Container | Same as above | +| `calcField()` | Calculation | Same as above | + +## Error Types + +| Error Type | Description | Type Guard | +|------------|-------------|------------| +| `HTTPError` | HTTP status errors (4xx, 5xx) | `isHTTPError()` | +| `ODataError` | OData protocol errors | `isODataError()` | +| `ValidationError` | Schema validation failures | `isValidationError()` | +| `TimeoutError` | Request timeout | `instanceof TimeoutError` | +| `NetworkError` | Network connectivity issues | `instanceof NetworkError` | +| `RetryLimitError` | Request failed after retries | `instanceof RetryLimitError` | +| `CircuitOpenError` | Circuit breaker is open | `instanceof CircuitOpenError` | +| `BatchTruncatedError` | Batch operation truncated | `isBatchTruncatedError()` | diff --git a/apps/docs/content/docs/fmodata/queries.mdx b/apps/docs/content/docs/fmodata/queries.mdx new file mode 100644 index 00000000..1af34f6f --- /dev/null +++ b/apps/docs/content/docs/fmodata/queries.mdx @@ -0,0 +1,241 @@ +--- +title: Querying Data +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + +## Basic Queries + +Use `list()` to retrieve multiple records: + +```typescript +// Get all users +const result = await db.from(users).list().execute(); + +if (result.data) { + result.data.forEach((user) => { + console.log(user.username); + }); +} +``` + +Get a specific record by ID: + +```typescript +const result = await db.from(users).get("user-123").execute(); + +if (result.data) { + console.log(result.data.username); +} +``` + +Get a single field value: + +```typescript +const result = await db + .from(users) + .get("user-123") + .getSingleField(users.email) + .execute(); + +if (result.data) { + console.log(result.data); // "user@example.com" +} +``` + +## Filtering + +fmodata provides type-safe filter operations that prevent common errors at compile time. Use the ORM-style API with operators and column references: + +```typescript +import { eq, gt, and, or, contains } from "@proofkit/fmodata"; + +// Simple equality +const result = await db + .from(users) + .list() + .where(eq(users.active, true)) + .execute(); + +// Comparison operators +const result = await db.from(users).list().where(gt(users.age, 18)).execute(); + +// String operators +const result = await db + .from(users) + .list() + .where(contains(users.name, "John")) + .execute(); + +// Combine with AND +const result = await db + .from(users) + .list() + .where(and(eq(users.active, true), gt(users.age, 18))) + .execute(); + +// Combine with OR +const result = await db + .from(users) + .list() + .where(or(eq(users.role, "admin"), eq(users.role, "moderator"))) + .execute(); +``` + +### Available Operators + +**Comparison:** +- `eq()` - Equal to +- `ne()` - Not equal to +- `gt()` - Greater than +- `gte()` - Greater than or equal to +- `lt()` - Less than +- `lte()` - Less than or equal to + +**String:** +- `contains()` - Contains substring +- `startsWith()` - Starts with +- `endsWith()` - Ends with + +**Array:** +- `inArray()` - Value in array +- `notInArray()` - Value not in array + +**Null:** +- `isNull()` - Is null +- `isNotNull()` - Is not null + +**Logical:** +- `and()` - Logical AND +- `or()` - Logical OR +- `not()` - Logical NOT + +## Sorting + +Sort results using `orderBy()` with column references: + +```typescript +import { asc, desc } from "@proofkit/fmodata"; + +// Single field (ascending by default) +const result = await db.from(users).list().orderBy(users.name).execute(); + +// Single field with explicit direction +const result = await db.from(users).list().orderBy(asc(users.name)).execute(); +const result = await db.from(users).list().orderBy(desc(users.age)).execute(); + +// Multiple fields (variadic) +const result = await db + .from(users) + .list() + .orderBy(asc(users.lastName), desc(users.firstName)) + .execute(); + +// Multiple fields (array syntax) +const result = await db + .from(users) + .list() + .orderBy([ + [users.lastName, "asc"], + [users.firstName, "desc"], + ]) + .execute(); +``` + +## Pagination + +Control the number of records returned and pagination: + +```typescript +// Limit results +const result = await db.from(users).list().top(10).execute(); + +// Skip records (pagination) +const result = await db.from(users).list().top(10).skip(20).execute(); + +// Count total records +const result = await db.from(users).list().count().execute(); +``` + +## Selecting Fields + +Select specific fields to return using column references: + +```typescript +// Using column references (type-safe, supports renaming) +const result = await db + .from(users) + .list() + .select({ + username: users.username, + email: users.email, + userId: users.id, // Renamed from "id" to "userId" + }) + .execute(); + +// result.data[0] will only have username, email, and userId fields +``` + +## Single Records + +Use `single()` to ensure exactly one record is returned (returns an error if zero or multiple records are found): + +```typescript +const result = await db + .from(users) + .list() + .where(eq(users.email, "user@example.com")) + .single() + .execute(); + +if (result.data) { + // result.data is a single record, not an array + console.log(result.data.username); +} +``` + +Use `maybeSingle()` when you want at most one record (returns `null` if no record is found, returns an error if multiple records are found): + +```typescript +const result = await db + .from(users) + .list() + .where(eq(users.email, "user@example.com")) + .maybeSingle() + .execute(); + +if (result.data) { + // result.data is a single record or null + console.log(result.data?.username); +} else { + // No record found - result.data would be null + console.log("User not found"); +} +``` + +**Difference between `single()` and `maybeSingle()`:** + +- `single()` - Requires exactly one record. Returns an error if zero or multiple records are found. +- `maybeSingle()` - Allows zero or one record. Returns `null` if no record is found, returns an error only if multiple records are found. + +## Chaining Methods + +All query methods can be chained together: + +```typescript +const result = await db + .from(users) + .list() + .select({ + username: users.username, + email: users.email, + age: users.age, + }) + .where(gt(users.age, 18)) + .orderBy(asc(users.username)) + .top(10) + .skip(0) + .execute(); +``` + diff --git a/apps/docs/content/docs/fmodata/quick-start.mdx b/apps/docs/content/docs/fmodata/quick-start.mdx new file mode 100644 index 00000000..dc01ab02 --- /dev/null +++ b/apps/docs/content/docs/fmodata/quick-start.mdx @@ -0,0 +1,134 @@ +--- +title: Quick Start +--- + +import { Steps, Step } from "fumadocs-ui/components/steps"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "fumadocs-ui/components/tabs"; +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; +import { CliCommand } from "@/components/CliCommand"; +import { Badge } from "@/components/ui/badge"; + +Here's a minimal example to get you started with `@proofkit/fmodata`: + + + + ### Install the package + + ```package-install + @proofkit/fmodata + ``` + + + + + ### Create a server connection + + Create a connection to your FileMaker server using either username/password or API key authentication (requires OttoFMS 4.11+): + + ```typescript title="connection.ts" tab="Username/Password" + import { FMServerConnection } from "@proofkit/fmodata"; + + const connection = new FMServerConnection({ + serverUrl: process.env.FM_SERVER, + auth: { + username: process.env.FM_USERNAME, + password: process.env.FM_PASSWORD, + }, + }); + ``` + + ```typescript title="connection.ts" tab="API key" + import { FMServerConnection } from "@proofkit/fmodata"; + + const connection = new FMServerConnection({ + serverUrl: process.env.FM_SERVER, + auth: { + apiKey: process.env.OTTO_API_KEY, + }, + }); + ``` + + + + + ### Define your table schema + + + + + Automatically Recommended + Manually + + + Run this command in your project to launch a browser-based UI for configuring your schema definitions. You will need environment variables set for your FileMaker server and database. + + +Learn more about the [@proofkit/typegen](/docs/typegen) tool. + + + + Use field builders to create type-safe table schemas: + + ```typescript title="schema.ts" + import { + fmTableOccurrence, + textField, + numberField, + } from "@proofkit/fmodata"; + import { z } from "zod/v4"; + + const users = fmTableOccurrence("users", { + id: textField().primaryKey(), + username: textField().notNull(), + email: textField().notNull(), + active: numberField() + .readValidator(z.coerce.boolean()) + .writeValidator(z.boolean().transform((v) => (v ? 1 : 0))), + }); + ``` + + + + + + + + ### Create a database instance and query data + + Connect to your database and start querying: + + ```typescript title="query.ts" + import { eq } from "@proofkit/fmodata"; + import { connection } from "./connection"; + import { users } from "./schema"; + + const db = connection.database(process.env.FM_DATABASE); + + // Query all users + const { data, error } = await db.from(users).list().execute(); + + if (error) { + console.error(error); + return; + } + + if (data) { + console.log(data); // Array of users, properly typed + } + + // Filter active users + const activeUsers = await db + .from(users) + .list() + .where(eq(users.active, true)) + .execute(); + ``` + + + diff --git a/apps/docs/content/docs/fmodata/relationships.mdx b/apps/docs/content/docs/fmodata/relationships.mdx new file mode 100644 index 00000000..e9ee7250 --- /dev/null +++ b/apps/docs/content/docs/fmodata/relationships.mdx @@ -0,0 +1,212 @@ +--- +title: Related Data +description: How to retrieve related data from your FileMaker database +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + + +## FileMaker Relationships vs OData Navigation + +When FileMaker server parses your relationship graph for relationships, it can only create basic navigation paths between table occurences where the fields are equal to each other. Sorting via a relationship is not supported and other comparison operators (if used) will be ignored and everything will be treated as a simple equality match between the fields. For complex queries or sorting, you should use the other methods of OData. + +### Navigating vs Expanding + +When you navigate to a related table, are essentially changing the context of your query to the related table, but with a filtered subset of records related to the parent record(s). This is most often done if you know the primary key ID of the parent record and only need the children records. + +For example the query: `/users('123')/orders` will return all the orders for the user with the ID `123`. In @proofkit/fmodata, you can do this with the `navigate()` method. + +```typescript tab="Query" +const result = await db.from(users).get("123").navigate(orders).execute(); +``` + +```jsonc tab="Result" +// data is from the orders table; no fields from the users table will be included. +{ + "data": [ + { "id": "456", "amount": 100, "date": "2021-01-01" }, + { "id": "789", "amount": 200, "date": "2021-01-02" }, + ] +} +``` + +When you use the `expand()` method, you are essentially adding the related records to the current query. This is most often done if you need to get the parent records along with the children records. + +```typescript tab="Query" +const result = await db.from(users).get("123").expand(orders).execute(); +``` + +```jsonc tab="Result" +// data is from the users table, along with the related orders records. +{ + "data": { + "id": "123", + "name": "John Doe", + "email": "john.doe@example.com", + "orders": [ + { + "id": "456", + "amount": 100, + "date": "2021-01-01" + }, + { + "id": "789", + "amount": 200, + "date": "2021-01-02" + } + ] + } +} +``` + +## Defining Navigation Paths + +Define navigation paths using the `navigationPaths` option when creating table occurrences. The same navigation paths are used whether you use the `navigate()` or `expand()` methods. + + +These are created for you automatically when you use the `@proofkit/typegen` tool to generate your schema files. + + +```typescript +import { fmTableOccurrence, textField } from "@proofkit/fmodata"; + +const contacts = fmTableOccurrence( + "contacts", + { + id: textField().primaryKey(), + name: textField().notNull(), + userId: textField().notNull(), + }, + { + navigationPaths: ["users"], // Valid navigation targets + }, +); + +const users = fmTableOccurrence( + "users", + { + id: textField().primaryKey(), + username: textField().notNull(), + email: textField().notNull(), + }, + { + navigationPaths: ["contacts"], // Valid navigation targets + }, +); +``` + +The `navigationPaths` option: + +- Specifies which table occurrences can be navigated to from this table +- Enables runtime validation when using `expand()` or `navigate()` +- Throws descriptive errors if you try to navigate to an invalid path + +## Navigating Between Tables + +Navigate to related records: + +```typescript +// Navigate from a specific record (using column references) +const result = await db + .from(contacts) + .get("contact-123") + .navigate(users) + .select({ + username: users.username, + email: users.email, + }) + .execute(); + +// Navigate without specifying a record first +const result = await db.from(contacts).navigate(users).list().execute(); +``` + +## Expanding Related Records + +Use `expand()` to include related records in your query results. The library validates that the target table is in the source table's `navigationPaths`: + +```typescript +// Simple expand +const result = await db.from(contacts).list().expand(users).execute(); + +// Expand with field selection (using column references) +const result = await db + .from(contacts) + .list() + .expand(users, (b) => + b.select({ + username: users.username, + email: users.email, + }), + ) + .execute(); + +// Expand with filtering (using ORM API) +import { eq } from "@proofkit/fmodata"; + +const result = await db + .from(contacts) + .list() + .expand(users, (b) => b.where(eq(users.active, true))) + .execute(); + +// Multiple expands +const result = await db + .from(contacts) + .list() + .expand(users, (b) => b.select({ username: users.username })) + .expand(orders, (b) => b.select({ total: orders.total }).top(5)) + .execute(); +``` + +## Nested Expands + +You can nest expands to include related records of related records: + +```typescript +// Nested expands +const result = await db + .from(contacts) + .list() + .expand(users, (usersBuilder) => + usersBuilder + .select({ + username: users.username, + email: users.email, + }) + .expand(customers, (customerBuilder) => + customerBuilder.select({ + name: customers.name, + tier: customers.tier, + }), + ), + ) + .execute(); +``` + +## Complex Expand Examples + +Combine multiple options in a single expand: + +```typescript +// Complex expand with multiple options +const result = await db + .from(contacts) + .list() + .expand(users, (b) => + b + .select({ + username: users.username, + email: users.email, + }) + .where(eq(users.active, true)) + .orderBy(asc(users.username)) + .top(10) + .expand(customers, (nested) => nested.select({ name: customers.name })), + ) + .execute(); +``` + + + diff --git a/apps/docs/content/docs/fmodata/schema-management.mdx b/apps/docs/content/docs/fmodata/schema-management.mdx new file mode 100644 index 00000000..7ca71495 --- /dev/null +++ b/apps/docs/content/docs/fmodata/schema-management.mdx @@ -0,0 +1,82 @@ +--- +title: Schema Management +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +## Schema Management + +The library provides methods for managing database schema through the `db.schema` property. You can create and delete tables, add and remove fields, and manage indexes. + +### Creating Tables + +```typescript +import type { Field } from "@proofkit/fmodata"; + +const fields: Field[] = [ + { + name: "id", + type: "string", + primary: true, + maxLength: 36, + }, + { + name: "username", + type: "string", + nullable: false, + unique: true, + maxLength: 50, + }, + { + name: "email", + type: "string", + nullable: false, + maxLength: 255, + }, +]; + +const tableDefinition = await db.schema.createTable("users", fields); +console.log(tableDefinition.tableName); // "users" +console.log(tableDefinition.fields); // Array of field definitions +``` + +### Adding Fields + +```typescript +const newFields: Field[] = [ + { + name: "phone", + type: "string", + nullable: true, + maxLength: 20, + }, +]; + +const updatedTable = await db.schema.addFields("users", newFields); +``` + +### Deleting Tables and Fields + +```typescript +// Delete an entire table +await db.schema.deleteTable("old_table"); + +// Delete a specific field +await db.schema.deleteField("users", "old_field"); +``` + +### Managing Indexes + +```typescript +// Create an index +const index = await db.schema.createIndex("users", "email"); +console.log(index.indexName); // "email" + +// Delete an index +await db.schema.deleteIndex("users", "email"); +``` + + +Schema management operations require appropriate access privileges on your FileMaker account. Operations will throw errors if you don't have the necessary permissions. + + diff --git a/apps/docs/content/docs/fmodata/schema.mdx b/apps/docs/content/docs/fmodata/schema.mdx new file mode 100644 index 00000000..98c86296 --- /dev/null +++ b/apps/docs/content/docs/fmodata/schema.mdx @@ -0,0 +1,165 @@ +--- +title: Schema Definitions +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { TypeTable } from "fumadocs-ui/components/type-table"; +import { Badge } from "@/components/ui/badge"; + +This library relies on a schema-first approach for good type-safety and optional runtime validation. Use **`fmTableOccurrence()`** with field builders to create your schemas. This provides full TypeScript type inference for field names in queries. + +## Field Builders + +Field builders provide a fluent API for defining table fields with type-safe metadata. These field types map directly to the FileMaker field types: + +| Builder | FileMaker Type | Description | +|---------|---------------|-------------| +| `textField()` | Text | Text fields | +| `numberField()` | Number | Numeric fields | +| `dateField()` | Date | Date fields | +| `timeField()` | Time | Time fields | +| `timestampField()` | Timestamp | Date and time fields | +| `containerField()` | Container | Container fields | +| `calcField()` | Calculation | Calculated fields | + +### Chainable Methods + +Each field builder supports chainable methods: + +| Method | Description | +|--------|-------------| +| `.primaryKey()` | Mark as primary key (automatically read-only) | +| `.notNull()` | Forces the type to not be null and requires you provided a value when inserting | +| `.readOnly()` | Exclude from insert/update operations | +| `.entityId(id)` | Assign FileMaker field ID (FMFID), allowing your API calls to survive FileMaker name changes | +| `.readValidator(validator)` | Transform/validate data when reading from database (list and get operations) | +| `.writeValidator(validator)` | Transform/validate data when writing to database (insert and update operations) | + +## Defining Tables + +Use `fmTableOccurrence()` to define a table with field builders: + +```typescript title="schema.ts" +import { z } from "zod/v4"; +import { + fmTableOccurrence, + textField, + numberField, + timestampField, +} from "@proofkit/fmodata"; + +const contacts = fmTableOccurrence( + "contacts", + { + id: textField().primaryKey().entityId("FMFID:1"), + name: textField().notNull().entityId("FMFID:2"), + email: textField().notNull().entityId("FMFID:3"), + phone: textField().entityId("FMFID:4"), // Optional (nullable by default) + createdAt: timestampField().readOnly().entityId("FMFID:5"), + }, + { + entityId: "FMTID:100", // Optional: FileMaker table occurrence ID + defaultSelect: "schema", // Optional: "all", "schema", or function. Defaults to "schema". + navigationPaths: ["users"], // Optional: valid navigation targets + }, +); +``` + +The function returns a table object that can be used in queries and operations. To reference fields in a table, use the column references (e.g. in select and filter operations use `contacts.id`, `contacts.name`). Or use the object direclty to reference the table (such as in from, navigate, and expand operations). + +## Default Field Selection + +What should happen when you don't specify a `select` in your query? You can configure this at the table definition level using the `defaultSelect` option. + +### Option 1: `"schema"` Default +With this default option, the library will ensure there is always a `$select` parameter when making the request to FileMaker. The `$select` parameter will only include the fields that are defined in the schema, so you'll never get back any extra fields that are not defined in your schema. + +### Option 2: `"all"` +This option turns off the library behavior and will revert to FileMaker's default behavior. All non-container fields will be returned at runtime, but you will only get type information for the fields that are defined in the schema. +```typescript +const users = fmTableOccurrence( + "users", + { + /* fields */ + }, + { + defaultSelect: "all", + }, +); + +``` + +### Option 3: Custom Select +You can also provide a function that returns a custom select object. This function will be called with the table object and should return a custom select object. + +```typescript +const users = fmTableOccurrence( + "users", + { + /* fields */ + }, + { + defaultSelect: (cols) => ({ + username: cols.username, + email: cols.email, + }), // Only select these fields by default + }, +); +``` + +When you call `list()` or `get()`, the `defaultSelect` is applied automatically. You can still override with explicit `select()`: + +```typescript +const result = await db + .from(users) + .list() + .select({ username: users.username, email: users.email, age: users.age }) // Always overrides at the per-request level + .execute(); +``` + +## Read/Write Validators + +You can use Standard Schema validators to transform and validate data when reading from or writing to the database. We use Zod in our examples, but any other validation library that supports Standard Schema (Zod, Valibot, ArkType, etc.) will also work. + +```typescript +import { z } from "zod/v4"; + +const users = fmTableOccurrence("users", { + id: textField().primaryKey(), + username: textField().notNull(), + email: textField().notNull(), + active: numberField() + .readValidator(z.coerce.boolean()) // Convert 1/0 to true/false when reading + .writeValidator(z.boolean().transform((v) => (v ? 1 : 0))), // Convert true/false to 1/0 when writing +}); +``` + + +The validators must transform to/from the FileMaker data type. In the example above, the write validator would have thrown a type error if the result of the `transform` was a string. + + +## Required and Read-Only Fields + +The library automatically infers which fields are required based on field builder configuration: + +- Fields with `.notNull()` are automatically required for insert +- Fields with `.readOnly()` are excluded from insert/update operations +- Fields with `.primaryKey()` are automatically read-only + +```typescript +const users = fmTableOccurrence("users", { + id: textField().primaryKey(), // Auto-required, but excluded from insert (primaryKey) + username: textField().notNull(), // Auto-required (notNull) + email: textField().notNull(), // Auto-required (notNull) + phone: textField(), // Optional by default (nullable) + createdAt: timestampField().readOnly(), // Excluded from insert/update +}); + +// TypeScript enforces: username and email are required +// TypeScript excludes: id and createdAt cannot be provided +const result = await db.from(users).insert({ + username: "johndoe", + email: "john@example.com", + phone: "+1234567890", // Optional +}); +``` diff --git a/apps/docs/content/docs/fmodata/scripts.mdx b/apps/docs/content/docs/fmodata/scripts.mdx new file mode 100644 index 00000000..f0f5d3f5 --- /dev/null +++ b/apps/docs/content/docs/fmodata/scripts.mdx @@ -0,0 +1,75 @@ +--- +title: Running Scripts +description: Execute FileMaker scripts via OData +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + + +## Simple Script Execution + +```typescript +// Simple script execution +const result = await db.runScript("MyScriptName"); + +console.log(result.resultCode); // Script result code +console.log(result.result); // Optional script result string +``` + + + +OData doesn't support script names with special characters (e.g., `@`, `&`, `/`) or script names beginning with a number. + + +## Passing Parameters + +Pass parameters to scripts: + +```typescript +// Pass parameters to script +const result = await db.runScript("MyScriptName", { + scriptParam: "some value", +}); + +// Script parameters can be strings, numbers, or objects +const result = await db.runScript("ProcessOrder", { + scriptParam: { + orderId: "12345", + action: "approve", + }, +}); +``` + +## Validating Script Results + +Validate script result with a Standard Schema. + +```typescript +import { z } from "zod/v4"; + +// NOTE: Your validator must be able to parse a string. +// See Zod codecs for how to build a jsonCodec function that does this +// https://zod.dev/codecs?id=jsonschema + +const schema = jsonCodec( + z.object({ + success: z.boolean(), + message: z.string(), + recordId: z.string(), + }), +); + +const result = await db.runScript("CreateRecord", { + resultSchema: schema, +}); + +// result.result is now typed based on your schema +// An error will be thrown if the validator fails +console.log(result.result.recordId); +``` + + +In the example above, we use a [Zod codec](https://zod.dev/codecs?id=jsonschema) helper function to parse the result into a JSON object before validating. + + diff --git a/apps/docs/content/docs/fmodata/webhooks.mdx b/apps/docs/content/docs/fmodata/webhooks.mdx new file mode 100644 index 00000000..1c5d19f8 --- /dev/null +++ b/apps/docs/content/docs/fmodata/webhooks.mdx @@ -0,0 +1,177 @@ +--- +title: Webhooks +description: Tell your FileMaker Server to call a URL when data or schema changes +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Card } from "fumadocs-ui/components/card"; + + + Webhooks require FileMaker Server 22.0.4 or newer. + + + +Webhooks allow you to receive notifications when data changes in your FileMaker database. The library provides a type-safe API for managing webhooks through the `db.webhook` property. + +## Adding a Webhook + +Create a new webhook to monitor a table for changes: + +```typescript +// Basic webhook +const result = await db.webhook.add({ + webhook: "https://example.com/webhook", + tableName: contactsTable, +}); + +// Access the created webhook ID +console.log(result.webHookResult.webHookID); +``` + + +```typescript +// With custom headers +const result = await db.webhook.add({ + webhook: "https://example.com/webhook", + tableName: contactsTable, + headers: { + "X-Custom-Header": "value", + Authorization: "Bearer token", + }, + notifySchemaChanges: true, // Notify when schema changes +}); + +// With field selection (using column references) +const result = await db.webhook.add({ + webhook: "https://example.com/webhook", + tableName: contacts, + select: [contacts.name, contacts.email, contacts.PrimaryKey], +}); + +// With filtering (using filter expressions) +import { eq, gt } from "@proofkit/fmodata"; + +const result = await db.webhook.add({ + webhook: "https://example.com/webhook", + tableName: contacts, + filter: eq(contacts.active, true), + select: [contacts.name, contacts.email], +}); + +// Complex filter example +const result = await db.webhook.add({ + webhook: "https://example.com/webhook", + tableName: users, + filter: and(eq(users.active, true), gt(users.age, 18)), + select: [users.username, users.email], +}); +``` + +**Webhook Configuration Properties:** + +- `webhook` (required) - The URL to call when the webhook is triggered +- `tableName` (required) - The `FMTable` instance for the table to monitor +- `headers` (optional) - Custom headers to include in webhook requests +- `notifySchemaChanges` (optional) - Whether to notify on schema changes +- `select` (optional) - Field selection as a string or array of `Column` references +- `filter` (optional) - Filter expression (string or `FilterExpression`) to limit which records trigger the webhook + +## Listing Webhooks + +Get all webhooks configured for the database: + +```typescript +const result = await db.webhook.list(); + +console.log(result.Status); // Status of the operation +console.log(result.WebHook); // Array of webhook configurations + +result.WebHook.forEach((webhook) => { + console.log(`Webhook ${webhook.webHookID}:`); + console.log(` Table: ${webhook.tableName}`); + console.log(` URL: ${webhook.url}`); + console.log(` Notify Schema Changes: ${webhook.notifySchemaChanges}`); + console.log(` Select: ${webhook.select}`); + console.log(` Filter: ${webhook.filter}`); + console.log(` Pending Operations: ${webhook.pendingOperations.length}`); +}); +``` + +## Getting a Webhook + +Retrieve a specific webhook by ID: + +```typescript +const webhook = await db.webhook.get(1); + +console.log(webhook.webHookID); +console.log(webhook.tableName); +console.log(webhook.url); +console.log(webhook.headers); +console.log(webhook.notifySchemaChanges); +console.log(webhook.select); +console.log(webhook.filter); +console.log(webhook.pendingOperations); +``` + +## Removing a Webhook + +Delete a webhook by ID: + +```typescript +await db.webhook.remove(1); +``` + +## Invoking a Webhook + +Manually trigger a webhook. This is useful for testing or triggering webhooks on-demand: + +```typescript +// Invoke for all rows matching the webhook's filter +await db.webhook.invoke(1); + +// Invoke for specific row IDs +await db.webhook.invoke(1, { rowIDs: [63, 61] }); +``` + +## Complete Example + +Here's a complete example of setting up and managing webhooks: + +```typescript +import { eq } from "@proofkit/fmodata"; + +// Add a webhook to monitor active contacts +const addResult = await db.webhook.add({ + webhook: "https://api.example.com/webhooks/contacts", + tableName: contacts, + headers: { + "X-API-Key": "your-api-key", + }, + filter: eq(contacts.active, true), + select: [contacts.name, contacts.email, contacts.PrimaryKey], + notifySchemaChanges: false, +}); + +const webhookId = addResult.webHookResult.webHookID; +console.log(`Created webhook with ID: ${webhookId}`); + +// List all webhooks +const listResult = await db.webhook.list(); +console.log(`Total webhooks: ${listResult.WebHook.length}`); + +// Get the webhook we just created +const webhook = await db.webhook.get(webhookId); +console.log(`Webhook URL: ${webhook.url}`); + +// Manually invoke the webhook for specific records +await db.webhook.invoke(webhookId, { rowIDs: [1, 2, 3] }); + +// Remove the webhook when done +await db.webhook.remove(webhookId); +``` + + +Webhooks are triggered automatically by FileMaker when records matching the webhook's filter are created, updated, or deleted. The `invoke()` method allows you to manually trigger webhooks for testing or on-demand processing. + + diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 9f3c7abc..4d7459bc 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -31,6 +31,10 @@ A library for generating TypeScript types and validation schemas from FileMaker A library for interacting with the FileMaker WebViewer. [Learn more](/docs/webviewer) +### @proofkit/fmodata + +A strongly-typed FileMaker OData API client with full TypeScript inference and runtime validation. [Learn more](/docs/fmodata) (beta) + ### @proofkit/better-auth Self-hosted auth for your web apps, backed by your FileMaker database. [Learn more](/docs/better-auth) diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 09649e1a..4d4e441b 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -1,3 +1,3 @@ { - "pages": ["cli", "templates", "fmdapi", "webviewer", "typegen", "better-auth"] + "pages": ["cli", "templates", "fmdapi", "fmodata", "webviewer", "typegen", "better-auth"] } diff --git a/apps/docs/package.json b/apps/docs/package.json index 789954e7..a1451aea 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "@base-ui-components/react": "1.0.0-rc.0", "@proofkit/registry": "workspace:*", "@proofkit/typegen": "workspace:*", "@proofkit/webviewer": "workspace:*", @@ -31,7 +32,7 @@ "hono": "^4.9.0", "jiti": "^1.21.7", "lucide-react": "^0.511.0", - "next": "16.1.0", + "next": "16.1.1", "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/apps/docs/src/app/(home)/page.tsx b/apps/docs/src/app/(home)/page.tsx index 9750f359..59817465 100644 --- a/apps/docs/src/app/(home)/page.tsx +++ b/apps/docs/src/app/(home)/page.tsx @@ -10,6 +10,7 @@ import { Card } from "fumadocs-ui/components/card"; import { Cards } from "fumadocs-ui/components/card"; import { Code, + Database, Globe, RectangleEllipsis, Terminal, @@ -44,7 +45,7 @@ export default function HomePage() {

- A collection of tools for FileMaker-aware JavaScript applications + A collection of tools for FileMaker-aware TypeScript applications

For new and experienced developers alike, the ProofKit toolset is @@ -61,16 +62,31 @@ export default function HomePage() { } href="/docs/typegen" title={"Typegen"}> Automatically generate runtime validators and TypeScript files - from your own FileMaker layouts. + from your own FileMaker layouts or table occurrences. } + icon={} title="Filemaker Data API" href="/docs/fmdapi" > A type-safe API for your FileMaker layouts. Easily connect without worrying about token management. + } + title={ + + FileMaker OData API{" "} + + New + + + } + href="/docs/fmodata" + > + A strongly-typed OData API client with full TypeScript inference, + runtime validation, and a fluent query builder. + } title="FileMaker Webviewer" @@ -82,10 +98,10 @@ export default function HomePage() { } title={ - + Better-Auth Adapter{" "} - - beta + + Beta } diff --git a/apps/docs/src/components/base-badge/default.tsx b/apps/docs/src/components/base-badge/default.tsx new file mode 100644 index 00000000..24774897 --- /dev/null +++ b/apps/docs/src/components/base-badge/default.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@/components/ui/badge"; + +export default function BadgeDemo() { + return ( +

+ Primary + Secondary + Outline + Destructive + Success + Warning + Info +
+ ); +} diff --git a/apps/docs/src/components/ui/badge.tsx b/apps/docs/src/components/ui/badge.tsx index 02054139..ecbaa898 100644 --- a/apps/docs/src/components/ui/badge.tsx +++ b/apps/docs/src/components/ui/badge.tsx @@ -1,46 +1,280 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { mergeProps } from "@base-ui-components/react/merge-props"; +import { useRender } from "@base-ui-components/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +export interface BadgeProps + extends useRender.ComponentProps<"span">, + VariantProps { + asChild?: boolean; + dotClassName?: string; + disabled?: boolean; +} + +export interface BadgeButtonProps + extends useRender.ComponentProps<"button">, + VariantProps { + asChild?: boolean; +} + +export type BadgeDotProps = React.HTMLAttributes; const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + "inline-flex items-center justify-center border border-transparent font-medium focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:-ms-px [&_svg]:shrink-0", { variants: { variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + primary: "bg-primary text-primary-foreground", + secondary: "bg-secondary text-secondary-foreground", + success: + "bg-[var(--color-success-accent,var(--color-green-500))] text-[var(--color-success-foreground,var(--color-white))]", + warning: + "bg-[var(--color-warning-accent,var(--color-yellow-500))] text-[var(--color-warning-foreground,var(--color-white))]", + info: "bg-[var(--color-info-accent,var(--color-violet-500))] text-[var(--color-info-foreground,var(--color-white))]", outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + "bg-transparent border border-border text-secondary-foreground", + destructive: "bg-destructive text-destructive-foreground", + }, + appearance: { + default: "", + light: "", + outline: "", + ghost: "border-transparent bg-transparent", + }, + disabled: { + true: "opacity-50 pointer-events-none", + }, + size: { + lg: "rounded-md px-[0.5rem] h-7 min-w-7 gap-1.5 text-xs [&_svg]:size-3.5", + md: "rounded-md px-[0.45rem] h-6 min-w-6 gap-1.5 text-xs [&_svg]:size-3.5 ", + sm: "rounded-sm px-[0.325rem] h-5 min-w-5 gap-1 text-[0.6875rem] leading-[0.75rem] [&_svg]:size-3", + xs: "rounded-sm px-[0.25rem] h-4 min-w-4 gap-1 text-[0.625rem] leading-[0.5rem] [&_svg]:size-3", + }, + shape: { + default: "", + circle: "rounded-full", + }, + }, + compoundVariants: [ + /* Light */ + { + variant: "primary", + appearance: "light", + className: + "text-[var(--color-primary-accent,var(--color-blue-700))] bg-[var(--color-primary-soft,var(--color-blue-50))] dark:bg-[var(--color-primary-soft,var(--color-blue-950))] dark:text-[var(--color-primary-soft,var(--color-blue-600))]", + }, + { + variant: "secondary", + appearance: "light", + className: + "bg-secondary dark:bg-secondary/50 text-secondary-foreground", + }, + { + variant: "success", + appearance: "light", + className: + "text-[var(--color-success-accent,var(--color-green-800))] bg-[var(--color-success-soft,var(--color-green-100))] dark:bg-[var(--color-success-soft,var(--color-green-950))] dark:text-[var(--color-success-soft,var(--color-green-600))]", + }, + { + variant: "warning", + appearance: "light", + className: + "text-[var(--color-warning-accent,var(--color-yellow-700))] bg-[var(--color-warning-soft,var(--color-yellow-100))] dark:bg-[var(--color-warning-soft,var(--color-yellow-950))] dark:text-[var(--color-warning-soft,var(--color-yellow-600))]", + }, + { + variant: "info", + appearance: "light", + className: + "text-[var(--color-info-accent,var(--color-violet-700))] bg-[var(--color-info-soft,var(--color-violet-100))] dark:bg-[var(--color-info-soft,var(--color-violet-950))] dark:text-[var(--color-info-soft,var(--color-violet-400))]", + }, + { + variant: "destructive", + appearance: "light", + className: + "text-[var(--color-destructive-accent,var(--color-red-700))] bg-[var(--color-destructive-soft,var(--color-red-50))] dark:bg-[var(--color-destructive-soft,var(--color-red-950))] dark:text-[var(--color-destructive-soft,var(--color-red-600))]", + }, + /* Outline */ + { + variant: "primary", + appearance: "outline", + className: + "text-[var(--color-primary-accent,var(--color-blue-700))] border-[var(--color-primary-soft,var(--color-blue-100))] bg-[var(--color-primary-soft,var(--color-blue-50))] dark:bg-[var(--color-primary-soft,var(--color-blue-950))] dark:border-[var(--color-primary-soft,var(--color-blue-900))] dark:text-[var(--color-primary-soft,var(--color-blue-600))]", + }, + { + variant: "success", + appearance: "outline", + className: + "text-[var(--color-success-accent,var(--color-green-700))] border-[var(--color-success-soft,var(--color-green-200))] bg-[var(--color-success-soft,var(--color-green-50))] dark:bg-[var(--color-success-soft,var(--color-green-950))] dark:border-[var(--color-success-soft,var(--color-green-900))] dark:text-[var(--color-success-soft,var(--color-green-600))]", + }, + { + variant: "warning", + appearance: "outline", + className: + "text-[var(--color-warning-accent,var(--color-yellow-700))] border-[var(--color-warning-soft,var(--color-yellow-200))] bg-[var(--color-warning-soft,var(--color-yellow-50))] dark:bg-[var(--color-warning-soft,var(--color-yellow-950))] dark:border-[var(--color-warning-soft,var(--color-yellow-900))] dark:text-[var(--color-warning-soft,var(--color-yellow-600))]", + }, + { + variant: "info", + appearance: "outline", + className: + "text-[var(--color-info-accent,var(--color-violet-700))] border-[var(--color-info-soft,var(--color-violet-100))] bg-[var(--color-info-soft,var(--color-violet-50))] dark:bg-[var(--color-info-soft,var(--color-violet-950))] dark:border-[var(--color-info-soft,var(--color-violet-900))] dark:text-[var(--color-info-soft,var(--color-violet-400))]", + }, + { + variant: "destructive", + appearance: "outline", + className: + "text-[var(--color-destructive-accent,var(--color-red-700))] border-[var(--color-destructive-soft,var(--color-red-100))] bg-[var(--color-destructive-soft,var(--color-red-50))] dark:bg-[var(--color-destructive-soft,var(--color-red-950))] dark:border-[var(--color-destructive-soft,var(--color-red-900))] dark:text-[var(--color-destructive-soft,var(--color-red-600))]", + }, + /* Ghost */ + { + variant: "primary", + appearance: "ghost", + className: "text-primary", + }, + { + variant: "secondary", + appearance: "ghost", + className: "text-secondary-foreground", + }, + { + variant: "success", + appearance: "ghost", + className: "text-[var(--color-success-accent,var(--color-green-500))]", + }, + { + variant: "warning", + appearance: "ghost", + className: "text-[var(--color-warning-accent,var(--color-yellow-500))]", + }, + { + variant: "info", + appearance: "ghost", + className: "text-[var(--color-info-accent,var(--color-violet-500))]", + }, + { + variant: "destructive", + appearance: "ghost", + className: "text-destructive", + }, + + { size: "lg", appearance: "ghost", className: "px-0" }, + { size: "md", appearance: "ghost", className: "px-0" }, + { size: "sm", appearance: "ghost", className: "px-0" }, + { size: "xs", appearance: "ghost", className: "px-0" }, + ], + defaultVariants: { + variant: "primary", + appearance: "default", + size: "md", + }, + }, +); + +const badgeButtonVariants = cva( + "cursor-pointer transition-all inline-flex items-center justify-center leading-none size-3.5 [&>svg]:opacity-100! [&>svg]:size-3.5 p-0 rounded-md -me-0.5 opacity-60 hover:opacity-100", + { + variants: { + variant: { + default: "", }, }, defaultVariants: { variant: "default", }, - } -) + }, +); function Badge({ + render, + asChild = false, + children, className, variant, + size, + appearance, + shape, + disabled, + ...props +}: BadgeProps) { + const defaultProps = { + className: cn( + badgeVariants({ variant, size, appearance, shape, disabled }), + className, + ), + "data-slot": "badge", + }; + + // Determine render element based on asChild prop + const renderElement = + asChild && React.isValidElement(children) + ? (children as React.ReactElement< + Record, + string | React.JSXElementConstructor + >) + : render || ; + + // When using asChild, children becomes the element props, otherwise use children normally + const finalProps = + asChild && React.isValidElement(children) + ? mergeProps(defaultProps, props) + : mergeProps(defaultProps, { ...props, children }); + + const element = useRender({ + render: renderElement, + props: finalProps, + }); + + return element; +} + +function BadgeButton({ + render, asChild = false, + children, + className, + variant, ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" +}: BadgeButtonProps) { + const defaultProps = { + className: cn(badgeButtonVariants({ variant, className })), + role: "button" as const, + "data-slot": "badge-button", + }; + + // Determine render element based on asChild prop + const renderElement = + asChild && React.isValidElement(children) + ? (children as React.ReactElement< + Record, + string | React.JSXElementConstructor + >) + : render ||