velocious 1.0.700 → 1.0.702

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 (56) hide show
  1. package/README.md +1 -0
  2. package/build/configuration-types.js +73 -3
  3. package/build/configuration.js +113 -20
  4. package/build/frontend-model-resource/base-resource.js +150 -2
  5. package/build/frontend-models/resource-definition.js +47 -0
  6. package/build/src/configuration-types.d.ts +168 -7
  7. package/build/src/configuration-types.d.ts.map +1 -1
  8. package/build/src/configuration-types.js +68 -4
  9. package/build/src/configuration.d.ts +22 -2
  10. package/build/src/configuration.d.ts.map +1 -1
  11. package/build/src/configuration.js +110 -20
  12. package/build/src/frontend-model-resource/base-resource.d.ts +76 -3
  13. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  14. package/build/src/frontend-model-resource/base-resource.js +127 -3
  15. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  16. package/build/src/frontend-models/resource-definition.js +43 -1
  17. package/build/src/sync/local-mutation-log.d.ts +6 -3
  18. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  19. package/build/src/sync/local-mutation-log.js +11 -4
  20. package/build/src/sync/peer-mutation-bundle.d.ts +26 -0
  21. package/build/src/sync/peer-mutation-bundle.d.ts.map +1 -1
  22. package/build/src/sync/peer-mutation-bundle.js +7 -1
  23. package/build/src/sync/sync-api-client.d.ts +3 -1
  24. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  25. package/build/src/sync/sync-api-client.js +46 -8
  26. package/build/src/sync/sync-client-types.d.ts +24 -12
  27. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  28. package/build/src/sync/sync-client-types.js +7 -5
  29. package/build/src/sync/sync-client.d.ts +101 -0
  30. package/build/src/sync/sync-client.d.ts.map +1 -1
  31. package/build/src/sync/sync-client.js +236 -24
  32. package/build/src/sync/sync-offline-session.d.ts +328 -0
  33. package/build/src/sync/sync-offline-session.d.ts.map +1 -0
  34. package/build/src/sync/sync-offline-session.js +617 -0
  35. package/build/src/sync/sync-resource-base.d.ts +26 -28
  36. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  37. package/build/src/sync/sync-resource-base.js +11 -45
  38. package/build/sync/local-mutation-log.js +10 -3
  39. package/build/sync/peer-mutation-bundle.js +6 -0
  40. package/build/sync/sync-api-client.js +53 -7
  41. package/build/sync/sync-client-types.js +6 -4
  42. package/build/sync/sync-client.js +264 -21
  43. package/build/sync/sync-offline-session.js +713 -0
  44. package/build/sync/sync-resource-base.js +10 -53
  45. package/package.json +1 -1
  46. package/src/configuration-types.js +73 -3
  47. package/src/configuration.js +113 -20
  48. package/src/frontend-model-resource/base-resource.js +150 -2
  49. package/src/frontend-models/resource-definition.js +47 -0
  50. package/src/sync/local-mutation-log.js +10 -3
  51. package/src/sync/peer-mutation-bundle.js +6 -0
  52. package/src/sync/sync-api-client.js +53 -7
  53. package/src/sync/sync-client-types.js +6 -4
  54. package/src/sync/sync-client.js +264 -21
  55. package/src/sync/sync-offline-session.js +713 -0
  56. package/src/sync/sync-resource-base.js +10 -53
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, including committed counter-cache parent updates, structured per-attribute validation error responses, immutable per-operation remote request context, registration-local tenant subscription partitioning, and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/websocket-channels.md](docs/websocket-channels.md))
16
16
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
17
17
  * Declarative client sync scopes with per-scope cursors, a reusable observable single-flight coordinator with bounded retry and deterministic teardown, generation-fenced identity replacement and selected-scope cache reset, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery and reconnect gap closure, computed server-publish scope metadata with atomic null-safe publisher row identities, a shared public maintenance upsert helper and legacy-duplicate reconciliation, and immutable-handle project clients whose local database state plus remote pull/replay/realtime request context stay tenant-bound through reconnect (see [docs/sync-client.md](docs/sync-client.md), [docs/sync-coordinator.md](docs/sync-coordinator.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/offline-sync.md](docs/offline-sync.md))
18
+ * SyncClient long-offline sessions: framework-owned device-identity enrollment, backend-issued offline grants with per-grant retention, signing at queue time, signed batch replay with synced/conflict/rejected mapping, and signed peer bundle export/import through `sync.client.offline` (see [docs/sync-client.md](docs/sync-client.md#long-offline-sessions-grant-lifecycle-signed-queue-signed-replay-peer-api) and [docs/offline-sync.md](docs/offline-sync.md#implemented-slice-syncclient-offline-session-grant-lifecycle-signed-queue-signed-replay-peer-api))
18
19
  * Reactive `useLiveQuery(Model.where(...))` queries for default databases plus immutable-handle tenant live-query sources whose committed events and refreshes stay on the captured physical tenant (see [docs/live-queries.md](docs/live-queries.md))
19
20
  * Server-side sync envelope replay orchestration for app-owned sync receivers, including allowlisted authoritative conflict snapshots that retain submitted aliases in conflict metadata while keying `serverModel` by canonical model attributes (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
20
21
  * Self-sustaining sync feeds: upstream imports triggered by the changes pull itself, with framework-owned coalescing and throttling (see [docs/sync-upstream-imports.md](docs/sync-upstream-imports.md))
@@ -329,7 +329,7 @@
329
329
  * @property {string} [host] - Hostname to bind the HTTP server to.
330
330
  * @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
331
331
  * @property {number} [maxBufferedResponseBodyBytes] - Maximum UTF-8/binary byte length accepted by buffered response bodies. Unbounded when absent; streamed file responses are unaffected.
332
- * @property {number} [maxRequestBodyBytes] - Maximum decoded request-body bytes accepted before routing. Unbounded when absent.
332
+ * @property {number} [maxRequestBodyBytes] - Maximum decoded request-body bytes accepted before routing. Unbounded when absent, except the built-in MCP endpoint, which defaults to a 1 MiB cap that this option overrides.
333
333
  * @property {HttpRequestBodyPolicyResolver} [requestBodyPolicyResolver] - Optional synchronous header-stage resolver for per-request limits and raw-body handling.
334
334
  * @property {number} [maxWorkers] - Backward-compatible alias for workers.
335
335
  * @property {number} [port] - Port to bind the HTTP server to.
@@ -476,9 +476,19 @@
476
476
  * @property {"hasOne" | "hasMany"} type - Attachment cardinality.
477
477
  */
478
478
 
479
+ /**
480
+ * One declarative quick-search column. A plain string is a root-table column;
481
+ * `{path, column}` searches a column on a joined relationship path.
482
+ * @typedef {object} FrontendModelQuickSearchColumn
483
+ * @property {string[]} [path] - Relationship path from the root model to the column's table.
484
+ * @property {string} column - Database column name to search.
485
+ */
486
+
479
487
  /**
480
488
  * @typedef {object} FrontendModelResourceConfiguration
481
489
  * @property {Array<string | FrontendModelAttributeConfiguration> | Record<string, FrontendModelAttributeConfiguration | import("./database/drivers/base-column.js").default | boolean>} attributes - Attributes to expose on the frontend model.
490
+ * @property {Array<FrontendModelQuickSearchColumn | string>} [quickSearchColumns] - Declarative quick-search text columns; an index search on the pseudo-column `quickSearch` expands to an OR of LIKE conditions over these columns.
491
+ * @property {Array<FrontendModelQuickSearchColumn | string>} [quickSearchNumericColumns] - Declarative quick-search numeric columns; an integer quick-search value also matches these columns exactly.
482
492
  * @property {string[]} [abilities] - Additional camelCase ability action names to expose for per-record `record.can(action)` reads. Base CRUD actions (`read`, `create`, `update`, `destroy`) are always included and must not be listed here.
483
493
  * @property {Record<string, FrontendModelAttachmentConfiguration>} [attachments] - Attachment helpers keyed by attachment name.
484
494
  * @property {string[]} [commands] - Legacy built-in command names (`index`, `find`, `create`, `update`, `destroy`, `attach`, `download`, `url`).
@@ -588,17 +598,75 @@
588
598
  * @property {() => boolean | Promise<boolean>} [isOnline] - Connectivity gate for pulls and replays. Defaults to always online.
589
599
  * @property {string} [mountPath] - Mount path the server serves the sync endpoints under (match the server's `sync.api.mountPath`). Defaults to "/velocious/sync"; normalization strips trailing slashes and always fills in the default.
590
600
  * @property {(error: Error) => void} [onError] - Reports background replay/pull failures. Defaults to rethrowing.
601
+ * @property {VelociousSyncClientOfflineConfiguration} [offline] - Long-offline session configuration consumed by `startOfflineSession()`: device identity, enrollment, grant scopes, signed-replay path, and the backend public key for peer import.
591
602
  * @property {VelociousSyncClientRealtimeConfiguration} [realtime] - Realtime push configuration consumed by `subscribeRealtime(...)`.
592
603
  * @property {VelociousSyncClientTransport} transport - Transport posting to the framework sync endpoints (e.g. the frontend-model websocket client).
593
604
  * @property {VelociousSyncRealtimeWebsocketClient} [websocketClient] - Shared app-lifetime websocket client (the low-level form) that all sync traffic rides. Provide the same instance the frontend-model transport uses so one connection carries everything. When set, the realtime bridge subscribes channels on it and never owns its lifecycle: unsubscribing closes only channel subscriptions, leaving the socket connected.
594
605
  * @property {string | (() => string | null | undefined)} [websocketUrl] - Shared app-lifetime websocket URL. When set (and no `websocketClient` is given), the framework builds and owns one reconnecting `VelociousWebsocketClient` for all sync traffic, connected on first use.
595
606
  */
596
607
 
608
+ /**
609
+ * App-owned durable storage for the device signing identity (the backend-signed
610
+ * device certificate plus the device private key). Native apps back this with
611
+ * secure storage; the framework never embeds a storage technology.
612
+ * @typedef {object} VelociousSyncClientDeviceIdentityStorage
613
+ * @property {() => import("./sync/sync-offline-session.js").SyncOfflineDeviceIdentity | null | undefined | Promise<import("./sync/sync-offline-session.js").SyncOfflineDeviceIdentity | null | undefined>} load - Reads the stored identity (null when the device has none yet).
614
+ * @property {(identity: import("./sync/sync-offline-session.js").SyncOfflineDeviceIdentity) => Promise<void> | void} save - Persists the identity.
615
+ */
616
+
617
+ /**
618
+ * App-owned durable storage for retained offline grants. Pending mutations must
619
+ * always replay with the grant they were signed under, so a device that
620
+ * restarts while offline needs its issued grants back.
621
+ * @typedef {object} VelociousSyncClientGrantStorage
622
+ * @property {() => Array<import("./sync/offline-grant.js").SignedOfflineGrant> | Promise<Array<import("./sync/offline-grant.js").SignedOfflineGrant>>} load - Reads the retained grants.
623
+ * @property {(grants: Array<import("./sync/offline-grant.js").SignedOfflineGrant>) => Promise<void> | void} save - Persists the retained grants.
624
+ */
625
+
626
+ /**
627
+ * Client-side long-offline session configuration consumed by
628
+ * `SyncClient.startOfflineSession()`. The framework owns the session lifecycle
629
+ * (identity, grant bootstrap/refresh, signing, signed replay, peer bundles) over
630
+ * the built-in `/frontend-models/sync/bootstrap` and
631
+ * `/frontend-models/sync/replay` endpoints; the app only owns durable storage,
632
+ * the enrollment hook, and the grant scopes.
633
+ * @typedef {object} VelociousSyncClientOfflineConfiguration
634
+ * @property {VelociousSyncClientDeviceIdentityStorage} deviceIdentityStorage - App-owned durable device-identity storage adapter.
635
+ * @property {(args: {configuration: import("./configuration.js").default, publicKey: import("./sync/device-identity.js").SyncJsonWebKey}) => import("./sync/device-identity.js").DeviceCertificate | Promise<import("./sync/device-identity.js").DeviceCertificate>} enrollDevice - App-owned enrollment hook: asks the backend to register the device public key and returns the backend-signed certificate.
636
+ * @property {(args: {configuration: import("./configuration.js").default}) => Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue> | Promise<Record<string, import("./configuration-types.js").FrontendModelSyncJsonValue>>} grantScopes - Resolves the scopes the bootstrap request asks the backend to materialize into the grant.
637
+ * @property {string} [bootstrapPath] - Signed-bootstrap endpoint path. Defaults to "/frontend-models/sync/bootstrap".
638
+ * @property {string} [replayPath] - Signed-replay endpoint path. Defaults to "/frontend-models/sync/replay".
639
+ * @property {import("./sync/device-identity.js").SyncJsonWebKey} [backendPublicKey] - Backend public key used to verify device certificates and imported peer mutations.
640
+ * @property {VelociousSyncClientGrantStorage} [grantStorage] - Optional durable grant retention adapter.
641
+ * @property {import("./sync/local-mutation-log.js").default} [mutationLog] - Durable local mutation log the offline session replays and exports. Defaults to the conflict-tracking log declared by the synced models.
642
+ * @property {() => Date} [now] - Session clock. Defaults to the current time.
643
+ */
644
+
645
+ /**
646
+ * Normalized client-side long-offline session configuration. Same shape as
647
+ * {@link VelociousSyncClientOfflineConfiguration} with the defaults filled in.
648
+ * @typedef {VelociousSyncClientOfflineConfiguration & {
649
+ * bootstrapPath: string,
650
+ * configuration: import("./configuration.js").default,
651
+ * now: () => Date,
652
+ * replayPath: string
653
+ * }} NormalizedVelociousSyncClientOfflineConfiguration
654
+ */
655
+
656
+ /**
657
+ * Normalized client-side sync configuration. Same shape as
658
+ * {@link VelociousSyncClientConfiguration} with the defaults filled in.
659
+ * @typedef {VelociousSyncClientConfiguration & {
660
+ * mountPath: string,
661
+ * offline: NormalizedVelociousSyncClientOfflineConfiguration | undefined
662
+ * }} NormalizedVelociousSyncClientConfiguration
663
+ */
664
+
597
665
  /**
598
666
  * Velocious sync configuration.
599
667
  * @typedef {object} VelociousSyncConfiguration
600
668
  * @property {VelociousSyncApiConfiguration} [api] - Auto-mounts the Velocious sync changes/replay endpoints for this resource class.
601
- * @property {VelociousSyncClientConfiguration} [client] - Client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
669
+ * @property {NormalizedVelociousSyncClientConfiguration | undefined} client - Normalized client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
602
670
  * @property {import("./sync/device-identity.js").SyncJsonWebKey | null} [deviceCertificateBackendPublicKey] - Public backend key used to verify offline device certificates for sync replay.
603
671
  * @property {number} [changeFeedRetentionSize] - Number of accepted server changes retained before clients must refresh from snapshot.
604
672
  * @property {Array<import("./sync/offline-grant.js").OfflineGrantSigningKey>} offlineGrantSigningKeys - Signing keys used to issue and verify offline grants. Secrets must never be exposed to clients.
@@ -617,13 +685,15 @@
617
685
  */
618
686
 
619
687
  /**
620
- * @typedef {Omit<FrontendModelResourceConfiguration, "abilities" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "sync"> & {
688
+ * @typedef {Omit<FrontendModelResourceConfiguration, "abilities" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "quickSearchColumns" | "quickSearchNumericColumns" | "sync"> & {
621
689
  * abilities: FrontendModelResourceAbilitiesConfiguration
622
690
  * builtInCollectionCommands: Record<string, string>
623
691
  * builtInMemberCommands: Record<string, string>
624
692
  * collectionCommands: Record<string, string>
625
693
  * commandMetadata: Record<string, {args: Array<{name: string, type: string}>, returnType: string | null}>
626
694
  * memberCommands: Record<string, string>
695
+ * quickSearchColumns: FrontendModelQuickSearchColumn[] | null
696
+ * quickSearchNumericColumns: FrontendModelQuickSearchColumn[] | null
627
697
  * sync?: NormalizedFrontendModelResourceSyncConfiguration
628
698
  * }} NormalizedFrontendModelResourceConfiguration
629
699
  */
@@ -229,6 +229,14 @@ function normalizeHttpCompression(value) {
229
229
  }
230
230
  }
231
231
 
232
+ /**
233
+ * Default request-body limit for the built-in MCP endpoint. JSON-RPC messages are small,
234
+ * so the framework caps them instead of making every app that enables `mcp: true` restate
235
+ * a request-body policy. Explicit `httpServer.maxRequestBodyBytes` or a resolver limit wins.
236
+ * @type {number}
237
+ */
238
+ const DEFAULT_MCP_MAX_REQUEST_BODY_BYTES = 1024 * 1024
239
+
232
240
  export default class VelociousConfiguration {
233
241
  /**
234
242
  * Close database connections promise.
@@ -776,31 +784,58 @@ export default class VelociousConfiguration {
776
784
  resolveHttpRequestBodyPolicy(args) {
777
785
  const resolver = this.httpServer.requestBodyPolicyResolver
778
786
  const configuredPolicy = resolver ? resolver(args) : undefined
787
+ /** @type {import("./configuration-types.js").ResolvedHttpRequestBodyPolicy} */
788
+ let resolvedPolicy
779
789
 
780
790
  if (configuredPolicy === undefined) {
781
- return {maxRequestBodyBytes: this.httpServer.maxRequestBodyBytes, mode: "parsed"}
782
- }
791
+ resolvedPolicy = {maxRequestBodyBytes: this.httpServer.maxRequestBodyBytes, mode: "parsed"}
792
+ } else {
793
+ if (!configuredPolicy || typeof configuredPolicy !== "object" || Array.isArray(configuredPolicy)) {
794
+ throw new TypeError("httpServer.requestBodyPolicyResolver must return an object or undefined")
795
+ }
783
796
 
784
- if (!configuredPolicy || typeof configuredPolicy !== "object" || Array.isArray(configuredPolicy)) {
785
- throw new TypeError("httpServer.requestBodyPolicyResolver must return an object or undefined")
786
- }
797
+ const {maxRequestBodyBytes, mode = "parsed", ...restPolicy} = configuredPolicy
798
+ const unknownKeys = Object.keys(restPolicy)
787
799
 
788
- const {maxRequestBodyBytes, mode = "parsed", ...restPolicy} = configuredPolicy
789
- const unknownKeys = Object.keys(restPolicy)
800
+ if (unknownKeys.length > 0) {
801
+ throw new TypeError(`httpServer.requestBodyPolicyResolver returned unknown keys: ${unknownKeys.join(", ")} (supported: maxRequestBodyBytes, mode)`)
802
+ }
803
+ if (mode !== "parsed" && mode !== "raw") {
804
+ throw new TypeError(`httpServer.requestBodyPolicyResolver mode must be "parsed" or "raw", got: ${String(mode)}`)
805
+ }
790
806
 
791
- if (unknownKeys.length > 0) {
792
- throw new TypeError(`httpServer.requestBodyPolicyResolver returned unknown keys: ${unknownKeys.join(", ")} (supported: maxRequestBodyBytes, mode)`)
793
- }
794
- if (mode !== "parsed" && mode !== "raw") {
795
- throw new TypeError(`httpServer.requestBodyPolicyResolver mode must be "parsed" or "raw", got: ${String(mode)}`)
807
+ resolvedPolicy = {
808
+ maxRequestBodyBytes: maxRequestBodyBytes === undefined
809
+ ? this.httpServer.maxRequestBodyBytes
810
+ : optionalPositiveSafeInteger(maxRequestBodyBytes, "httpServer.requestBodyPolicyResolver maxRequestBodyBytes"),
811
+ mode
812
+ }
796
813
  }
797
814
 
798
- return {
799
- maxRequestBodyBytes: maxRequestBodyBytes === undefined
800
- ? this.httpServer.maxRequestBodyBytes
801
- : optionalPositiveSafeInteger(maxRequestBodyBytes, "httpServer.requestBodyPolicyResolver maxRequestBodyBytes"),
802
- mode
815
+ return this._applyMcpRequestBodyPolicyDefaults({args, policy: resolvedPolicy})
816
+ }
817
+
818
+ /**
819
+ * Enforces the built-in MCP endpoint's body contract without per-app plumbing: the
820
+ * JSON-RPC envelope is read from parsed params, so raw mode is rejected with an
821
+ * actionable error, and a framework default byte cap applies when the app configured none.
822
+ * @param {{args: import("./configuration-types.js").HttpRequestBodyPolicyResolverArgs, policy: import("./configuration-types.js").ResolvedHttpRequestBodyPolicy}} options - Resolution inputs.
823
+ * @returns {import("./configuration-types.js").ResolvedHttpRequestBodyPolicy} - Effective request policy.
824
+ */
825
+ _applyMcpRequestBodyPolicyDefaults({args, policy}) {
826
+ if (!this._mcp.enabled) return policy
827
+ if (args.httpMethod !== "POST") return policy
828
+ if (args.path.split("?")[0] !== this._mcp.path) return policy
829
+
830
+ if (policy.mode === "raw") {
831
+ throw new TypeError(
832
+ `The built-in MCP endpoint at ${this._mcp.path} requires parsed request bodies because its JSON-RPC envelope is read from params. Remove the raw-mode httpServer.requestBodyPolicyResolver entry for this path.`
833
+ )
803
834
  }
835
+
836
+ if (policy.maxRequestBodyBytes !== undefined) return policy
837
+
838
+ return {maxRequestBodyBytes: DEFAULT_MCP_MAX_REQUEST_BODY_BYTES, mode: policy.mode}
804
839
  }
805
840
 
806
841
  /**
@@ -865,7 +900,7 @@ export default class VelociousConfiguration {
865
900
  /**
866
901
  * Normalizes client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
867
902
  * @param {import("./configuration-types.js").VelociousSyncClientConfiguration | undefined} client - Client-side sync configuration.
868
- * @returns {import("./configuration-types.js").VelociousSyncClientConfiguration | undefined} - Normalized client-side sync configuration.
903
+ * @returns {import("./configuration-types.js").NormalizedVelociousSyncClientConfiguration | undefined} - Normalized client-side sync configuration.
869
904
  */
870
905
  _normalizeSyncClientConfiguration(client) {
871
906
  if (client === undefined || client === null) return undefined
@@ -874,11 +909,11 @@ export default class VelociousConfiguration {
874
909
  throw new Error("sync.client must be an object with transport and authenticationToken")
875
910
  }
876
911
 
877
- const {authenticationToken, batchSize, isOnline, mountPath, onError, realtime, transport, websocketClient, websocketUrl, ...restClient} = client
912
+ const {authenticationToken, batchSize, isOnline, mountPath, onError, offline, realtime, transport, websocketClient, websocketUrl, ...restClient} = client
878
913
  const restClientKeys = Object.keys(restClient)
879
914
 
880
915
  if (restClientKeys.length > 0) {
881
- throw new Error(`sync.client received unknown keys: ${restClientKeys.join(", ")} (supported: authenticationToken, batchSize, isOnline, mountPath, onError, realtime, transport, websocketClient, websocketUrl)`)
916
+ throw new Error(`sync.client received unknown keys: ${restClientKeys.join(", ")} (supported: authenticationToken, batchSize, isOnline, mountPath, onError, offline, realtime, transport, websocketClient, websocketUrl)`)
882
917
  }
883
918
  if (!transport || typeof transport !== "object" || typeof transport.post !== "function") {
884
919
  throw new Error("sync.client.transport must be an object with a post(path, body) method (like the frontend-model websocket client)")
@@ -910,6 +945,7 @@ export default class VelociousConfiguration {
910
945
  batchSize,
911
946
  isOnline,
912
947
  mountPath: (mountPath || "/velocious/sync").replace(/\/+$/u, "") || "/",
948
+ offline: this._normalizeSyncClientOfflineConfiguration(offline),
913
949
  onError,
914
950
  realtime,
915
951
  transport,
@@ -918,6 +954,63 @@ export default class VelociousConfiguration {
918
954
  }
919
955
  }
920
956
 
957
+ /**
958
+ * Normalizes the client-side long-offline session configuration consumed by
959
+ * `SyncClient.startOfflineSession()`. The framework owns the session
960
+ * lifecycle; the app provides durable identity storage, the enrollment hook,
961
+ * and the grant scopes callback.
962
+ * @param {import("./configuration-types.js").VelociousSyncClientOfflineConfiguration | undefined} offline - Offline configuration block.
963
+ * @returns {import("./configuration-types.js").NormalizedVelociousSyncClientOfflineConfiguration | undefined} - Normalized offline configuration.
964
+ */
965
+ _normalizeSyncClientOfflineConfiguration(offline) {
966
+ if (offline === undefined || offline === null) return undefined
967
+
968
+ if (typeof offline !== "object" || Array.isArray(offline)) {
969
+ throw new Error("sync.client.offline must be an object with deviceIdentityStorage, enrollDevice, and grantScopes")
970
+ }
971
+
972
+ const {backendPublicKey, bootstrapPath, deviceIdentityStorage, enrollDevice, grantScopes, grantStorage, mutationLog, now, replayPath, ...restOffline} = offline
973
+ const restOfflineKeys = Object.keys(restOffline)
974
+
975
+ if (restOfflineKeys.length > 0) {
976
+ throw new Error(`sync.client.offline received unknown keys: ${restOfflineKeys.join(", ")} (supported: backendPublicKey, bootstrapPath, deviceIdentityStorage, enrollDevice, grantScopes, grantStorage, mutationLog, now, replayPath)`)
977
+ }
978
+ if (!deviceIdentityStorage || typeof deviceIdentityStorage !== "object" || Array.isArray(deviceIdentityStorage) || typeof deviceIdentityStorage.load !== "function" || typeof deviceIdentityStorage.save !== "function") {
979
+ throw new Error("sync.client.offline.deviceIdentityStorage must be an object with load() and save(identity) methods")
980
+ }
981
+ if (typeof enrollDevice !== "function") throw new Error("sync.client.offline.enrollDevice must be a function resolving the backend-signed device certificate")
982
+ if (typeof grantScopes !== "function") throw new Error("sync.client.offline.grantScopes must be a function resolving the bootstrap grant scopes")
983
+ if (backendPublicKey !== undefined && (typeof backendPublicKey !== "object" || backendPublicKey === null || Array.isArray(backendPublicKey))) {
984
+ throw new Error("sync.client.offline.backendPublicKey must be a public JSON Web Key object")
985
+ }
986
+ if (bootstrapPath !== undefined && (typeof bootstrapPath !== "string" || !bootstrapPath.startsWith("/"))) {
987
+ throw new Error(`sync.client.offline.bootstrapPath must start with '/', got: ${String(bootstrapPath)}`)
988
+ }
989
+ if (replayPath !== undefined && (typeof replayPath !== "string" || !replayPath.startsWith("/"))) {
990
+ throw new Error(`sync.client.offline.replayPath must start with '/', got: ${String(replayPath)}`)
991
+ }
992
+ if (grantStorage !== undefined && (typeof grantStorage !== "object" || grantStorage === null || Array.isArray(grantStorage) || typeof grantStorage.load !== "function" || typeof grantStorage.save !== "function")) {
993
+ throw new Error("sync.client.offline.grantStorage must be an object with load() and save(grants) methods")
994
+ }
995
+ if (mutationLog !== undefined && (typeof mutationLog !== "object" || mutationLog === null || typeof mutationLog.append !== "function")) {
996
+ throw new Error("sync.client.offline.mutationLog must be a LocalMutationLog")
997
+ }
998
+ if (now !== undefined && typeof now !== "function") throw new Error("sync.client.offline.now must be a function resolving the session clock")
999
+
1000
+ return {
1001
+ backendPublicKey,
1002
+ bootstrapPath: (bootstrapPath || "/frontend-models/sync/bootstrap").replace(/\/+$/u, "") || "/",
1003
+ configuration: this,
1004
+ deviceIdentityStorage,
1005
+ enrollDevice,
1006
+ grantScopes,
1007
+ grantStorage,
1008
+ mutationLog,
1009
+ now: now || (() => new Date()),
1010
+ replayPath: (replayPath || "/frontend-models/sync/replay").replace(/\/+$/u, "") || "/"
1011
+ }
1012
+ }
1013
+
921
1014
  /**
922
1015
  * Normalizes sync API endpoint configuration.
923
1016
  * @param {import("./configuration-types.js").VelociousSyncApiConfiguration | undefined} api - Sync API configuration.
@@ -29,6 +29,7 @@ import VelociousError from "../velocious-error.js"
29
29
  * applyFrontendModelPagination: (args: {pagination: FrontendModelResourcePagination, query: import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>}) => void,
30
30
  * applyFrontendModelSearch: (args: {query: import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>, search: FrontendModelResourceSearch}) => void,
31
31
  * applyFrontendModelSort: (args: {query: import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>, sort: FrontendModelResourceSort}) => void,
32
+ * ensureFrontendModelJoinPath: (args: {path: string[], query: import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>}) => void,
32
33
  * frontendModelAbilityAction: (action: FrontendModelResourceAction) => string,
33
34
  * frontendModelAbilityAuthorizedQuery: (action: FrontendModelResourceAction, options?: FrontendModelResourceAuthorizedQueryOptions) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
34
35
  * frontendModelAuthorizedQuery: (action: FrontendModelResourceAction, options?: FrontendModelResourceAuthorizedQueryOptions) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
@@ -195,6 +196,9 @@ export function frontendModelResourceInternalConstructor(ResourceClass) {
195
196
  return /** @type {FrontendModelResourceInternalConstructor<typeof import("../database/record/index.js").default, typeof import("../database/record/index.js").default>} */ (/** @type {unknown} */ (ResourceClass))
196
197
  }
197
198
 
199
+ const QUICK_SEARCH_COLUMN = "quickSearch"
200
+
201
+
198
202
  /**
199
203
  * Base class for backend frontend-model resources.
200
204
  * @template {FrontendModelResourceModelClass} [TModelClass=typeof import("../database/record/index.js").default]
@@ -235,6 +239,23 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
235
239
  /** @type {ReturnType<typeof JSON.parse>} */
236
240
  static SharedResource = undefined
237
241
 
242
+ /**
243
+ * Declarative quick-search text columns. When declared, an index search on
244
+ * the pseudo-column `quickSearch` expands to an OR of LIKE conditions over
245
+ * these columns instead of hitting the controller default. Each entry is a
246
+ * root-table column name or a `{path, column}` pair for a joined
247
+ * relationship path.
248
+ * @type {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string> | null | undefined} */
249
+ static quickSearchColumns = undefined
250
+
251
+ /**
252
+ * Declarative quick-search numeric columns. When the trimmed quick-search
253
+ * value is a safe non-negative integer, each declared column also gets an
254
+ * exact `=` condition ORed into the quick-search predicate. Same entry shape
255
+ * as {@link FrontendModelBaseResource.quickSearchColumns}.
256
+ * @type {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string> | null | undefined} */
257
+ static quickSearchNumericColumns = undefined
258
+
238
259
  /**
239
260
  * Declarative writable-attribute permit list (camelCase attribute names)
240
261
  * used as the default {@link FrontendModelBaseResource#permittedParams} and
@@ -282,7 +303,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
282
303
  /**
283
304
  * Reads a static resource config value from the environment resource first,
284
305
  * then from the shared resource.
285
- * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "relationships" | "server" | "sync" | "translatedAttributes" | "writableAttributes"} name - Static config property name.
306
+ * @param {"abilities" | "attachments" | "attributes" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands" | "modelName" | "primaryKey" | "quickSearchColumns" | "quickSearchNumericColumns" | "relationships" | "server" | "sync" | "translatedAttributes" | "writableAttributes"} name - Static config property name.
286
307
  * @returns {ReturnType<typeof JSON.parse>} - Resolved config value.
287
308
  */
288
309
  static sharedResourceStaticValue(name) {
@@ -459,6 +480,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
459
480
  const memberCommands = this.sharedResourceStaticValue("memberCommands")
460
481
  const modelName = this.sharedResourceStaticValue("modelName")
461
482
  const primaryKey = this.sharedResourceStaticValue("primaryKey")
483
+ const quickSearchColumns = this.sharedResourceStaticValue("quickSearchColumns")
484
+ const quickSearchNumericColumns = this.sharedResourceStaticValue("quickSearchNumericColumns")
462
485
  const relationships = this.sharedResourceStaticValue("relationships")
463
486
  const server = this.sharedResourceStaticValue("server")
464
487
  const sync = this.sharedResourceStaticValue("sync")
@@ -476,6 +499,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
476
499
  if (memberCommands) config.memberCommands = /** @type {string[]} */ (memberCommands)
477
500
  if (modelName) config.modelName = /** @type {string} */ (modelName)
478
501
  if (primaryKey) config.primaryKey = /** @type {string | string[]} */ (primaryKey)
502
+ if (quickSearchColumns !== undefined) config.quickSearchColumns = /** @type {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string>} */ (quickSearchColumns)
503
+ if (quickSearchNumericColumns !== undefined) config.quickSearchNumericColumns = /** @type {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string>} */ (quickSearchNumericColumns)
479
504
  if (relationships) config.relationships = /** @type {string[]} */ (relationships)
480
505
  if (server) config.server = /** @type {import("../configuration-types.js").FrontendModelResourceServerConfiguration} */ (server)
481
506
  if (sync !== undefined) config.sync = /** @type {import("../configuration-types.js").FrontendModelResourceSyncConfiguration | boolean} */ (sync)
@@ -949,7 +974,8 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
949
974
  }
950
975
 
951
976
  /**
952
- * Applies frontend-model index search.
977
+ * Applies frontend-model index searches, expanding a declared quick search
978
+ * before falling back to the controller default.
953
979
  * @param {object} args - Search args.
954
980
  * @param {FrontendModelResourceController} args.controller - Controller handling the query.
955
981
  * @param {FrontendModelResourceAnyQuery} args.query - Query instance.
@@ -957,9 +983,118 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
957
983
  * @returns {void}
958
984
  */
959
985
  applyFrontendModelIndexSearch({controller, query, search}) {
986
+ if (this.applyQuickSearch({controller, query, search})) return
987
+
960
988
  controller.applyFrontendModelSearch({query, search})
961
989
  }
962
990
 
991
+ /**
992
+ * Expands a `quickSearch` pseudo-column search into an OR of LIKE conditions
993
+ * over the declared {@link FrontendModelBaseResource.quickSearchColumns}, plus
994
+ * exact `=` conditions over {@link FrontendModelBaseResource.quickSearchNumericColumns}
995
+ * when the trimmed value is a safe non-negative integer.
996
+ * @param {object} args - Search args.
997
+ * @param {FrontendModelResourceController} args.controller - Controller handling the query.
998
+ * @param {FrontendModelResourceAnyQuery} args.query - Query to filter.
999
+ * @param {FrontendModelResourceSearch} args.search - Search payload.
1000
+ * @returns {boolean} - Whether the search was handled as a quick search.
1001
+ */
1002
+ applyQuickSearch({controller, query, search}) {
1003
+ const quickSearchColumns = this.quickSearchColumnDefinitions()
1004
+
1005
+ if (!quickSearchColumns || quickSearchColumns.length === 0) return false
1006
+ if (search.path.length > 0 || search.column !== QUICK_SEARCH_COLUMN) return false
1007
+
1008
+ if (search.operator !== "like") {
1009
+ throw VelociousError.safe(`${this.quickSearchErrorLabel()} must use the like operator.`, {code: this.quickSearchErrorCode()})
1010
+ }
1011
+
1012
+ if (typeof search.value !== "string") {
1013
+ throw VelociousError.safe(`${this.quickSearchErrorLabel()} must be a string.`, {code: this.quickSearchErrorCode()})
1014
+ }
1015
+
1016
+ const trimmedValue = search.value.trim()
1017
+
1018
+ if (!trimmedValue) return true
1019
+
1020
+ const conditions = quickSearchColumns.map((column) => (
1021
+ `${this.quickSearchColumnSql({column, controller, query})} LIKE ${query.driver.quote(`%${trimmedValue}%`)}`
1022
+ ))
1023
+ const numericSearchValue = quickSearchNumericValue(trimmedValue)
1024
+
1025
+ if (numericSearchValue !== null) {
1026
+ const quickSearchNumericColumns = this.quickSearchNumericColumnDefinitions() || []
1027
+
1028
+ for (const column of quickSearchNumericColumns) {
1029
+ conditions.push(`${this.quickSearchColumnSql({column, controller, query})} = ${query.driver.quote(numericSearchValue)}`)
1030
+ }
1031
+ }
1032
+
1033
+ query.where(`(${conditions.join(" OR ")})`)
1034
+
1035
+ return true
1036
+ }
1037
+
1038
+ /**
1039
+ * Resolves the declared quick-search text columns through the shared resource.
1040
+ * @returns {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string> | null | undefined} - Declared columns.
1041
+ */
1042
+ quickSearchColumnDefinitions() {
1043
+ const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
1044
+
1045
+ return /** @type {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string> | null | undefined} */ (ResourceClass.sharedResourceStaticValue("quickSearchColumns"))
1046
+ }
1047
+
1048
+ /**
1049
+ * Resolves the declared quick-search numeric columns through the shared resource.
1050
+ * @returns {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string> | null | undefined} - Declared columns.
1051
+ */
1052
+ quickSearchNumericColumnDefinitions() {
1053
+ const ResourceClass = /** @type {typeof FrontendModelBaseResource} */ (this.constructor)
1054
+
1055
+ return /** @type {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn | string> | null | undefined} */ (ResourceClass.sharedResourceStaticValue("quickSearchNumericColumns"))
1056
+ }
1057
+
1058
+ /**
1059
+ * Builds the quoted SQL reference for one quick-search column, adding the
1060
+ * relationship join when the column sits on a joined path.
1061
+ * @param {object} args - Column args.
1062
+ * @param {import("../configuration-types.js").FrontendModelQuickSearchColumn | string} args.column - Column declaration.
1063
+ * @param {FrontendModelResourceController} args.controller - Controller handling the query.
1064
+ * @param {FrontendModelResourceAnyQuery} args.query - Query being filtered.
1065
+ * @returns {string} - Quoted SQL column reference.
1066
+ */
1067
+ quickSearchColumnSql({column, controller, query}) {
1068
+ const columnConfig = typeof column === "string" ? {column} : column
1069
+ const path = columnConfig.path || []
1070
+
1071
+ if (path.length > 0) {
1072
+ controller.ensureFrontendModelJoinPath({path, query})
1073
+ }
1074
+
1075
+ const tableReference = query.getTableReferenceForJoin(...path)
1076
+
1077
+ return `${query.driver.quoteTable(tableReference)}.${query.driver.quoteColumn(columnConfig.column)}`
1078
+ }
1079
+
1080
+ /**
1081
+ * Human-readable label used in invalid quick-search payload errors. Owners
1082
+ * with an established public error contract may override this.
1083
+ * @returns {string} - Error label.
1084
+ */
1085
+ quickSearchErrorLabel() {
1086
+ return "Quick search"
1087
+ }
1088
+
1089
+ /**
1090
+ * Error code used for invalid quick-search payload errors. Owners with an
1091
+ * established public error contract may override this.
1092
+ * @returns {string} - Error code.
1093
+ */
1094
+ quickSearchErrorCode() {
1095
+ return "invalid-quick-search"
1096
+ }
1097
+
963
1098
  /**
964
1099
  * Applies frontend-model index sort.
965
1100
  * @param {object} args - Sort args.
@@ -2125,3 +2260,16 @@ function filterWritableFrontendModelAttributes(
2125
2260
 
2126
2261
  return writableAttributes
2127
2262
  }
2263
+
2264
+ /**
2265
+ * Parses a trimmed quick-search value as a safe non-negative integer.
2266
+ * @param {string} searchValue - Trimmed quick-search value.
2267
+ * @returns {number | null} - Numeric search value, or null when not numeric.
2268
+ */
2269
+ function quickSearchNumericValue(searchValue) {
2270
+ if (!/^[0-9]+$/u.test(searchValue)) return null
2271
+
2272
+ const numericValue = Number.parseInt(searchValue, 10)
2273
+
2274
+ return Number.isSafeInteger(numericValue) ? numericValue : null
2275
+ }
@@ -27,6 +27,7 @@ const RESOURCE_STATIC_CONFIG_KEYS = new Set([
27
27
  "ModelClass",
28
28
  "primaryKey",
29
29
  "quickSearchColumns",
30
+ "quickSearchNumericColumns",
30
31
  "relationships",
31
32
  "ReplayServiceClass",
32
33
  "server",
@@ -168,6 +169,8 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
168
169
  "memberCommands",
169
170
  "modelName",
170
171
  "primaryKey",
172
+ "quickSearchColumns",
173
+ "quickSearchNumericColumns",
171
174
  "relationships",
172
175
  "server",
173
176
  "sync"
@@ -180,6 +183,8 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
180
183
 
181
184
  const normalizedCommands = normalizeFrontendModelResourceCommands(resourceConfiguration)
182
185
  const sync = normalizeFrontendModelResourceSync(resourceConfiguration)
186
+ const quickSearchColumns = normalizeFrontendModelQuickSearchColumns(resourceConfiguration.quickSearchColumns, "quickSearchColumns")
187
+ const quickSearchNumericColumns = normalizeFrontendModelQuickSearchColumns(resourceConfiguration.quickSearchNumericColumns, "quickSearchNumericColumns")
183
188
 
184
189
  return {
185
190
  ...resourceConfiguration,
@@ -192,6 +197,8 @@ function normalizeFrontendModelResourceConfiguration(resourceConfiguration) {
192
197
  // generator uses it to type each custom command method.
193
198
  commandMetadata: normalizedCommands.commandMetadata,
194
199
  memberCommands: normalizedCommands.memberCommands,
200
+ quickSearchColumns,
201
+ quickSearchNumericColumns,
195
202
  sync
196
203
  }
197
204
  }
@@ -518,6 +525,46 @@ function normalizeSyncOperations(operations) {
518
525
  return [...new Set(normalized)].sort()
519
526
  }
520
527
 
528
+ /**
529
+ * Normalizes declarative quick-search column entries into a stable list.
530
+ * Each entry is a plain column-name string or a `{path, column}` object whose
531
+ * path is a relationship path from the root model.
532
+ * @param {unknown} columns - Raw quick-search column declarations.
533
+ * @param {string} label - Config key label for error messages.
534
+ * @returns {Array<import("../configuration-types.js").FrontendModelQuickSearchColumn> | null} - Normalized columns, or null when undeclared.
535
+ */
536
+ function normalizeFrontendModelQuickSearchColumns(columns, label) {
537
+ if (columns === undefined || columns === null) return null
538
+
539
+ if (!Array.isArray(columns)) throw new Error(`Resource ${label} must be an array of column names or {path, column} objects.`)
540
+
541
+ return columns.map((entry) => {
542
+ if (typeof entry === "string") {
543
+ if (entry.length < 1) throw new Error(`Resource ${label} entries must be non-empty column names.`)
544
+
545
+ return {column: entry}
546
+ }
547
+
548
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
549
+ throw new Error(`Resource ${label} entries must be column names or {path, column} objects, got: ${JSON.stringify(entry)}`)
550
+ }
551
+
552
+ const entryRecord = /** @type {Record<string, unknown>} */ (entry)
553
+ const column = entryRecord.column
554
+ const path = entryRecord.path
555
+
556
+ if (typeof column !== "string" || column.length < 1) throw new Error(`Resource ${label} entries require a non-empty column name.`)
557
+
558
+ if (path === undefined || path === null) return {column}
559
+
560
+ if (!Array.isArray(path) || path.some((pathSegment) => typeof pathSegment !== "string" || pathSegment.length < 1)) {
561
+ throw new Error(`Resource ${label} entry paths must be arrays of non-empty relationship names.`)
562
+ }
563
+
564
+ return {column, path: [...path]}
565
+ })
566
+ }
567
+
521
568
  /**
522
569
  * Builds a deterministic policy hash.
523
570
  * @param {unknown} value - Hash input.