velocious 1.0.701 → 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 +72 -2
  3. package/build/configuration.js +61 -3
  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 +166 -5
  7. package/build/src/configuration-types.d.ts.map +1 -1
  8. package/build/src/configuration-types.js +67 -3
  9. package/build/src/configuration.d.ts +11 -2
  10. package/build/src/configuration.d.ts.map +1 -1
  11. package/build/src/configuration.js +61 -4
  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 +72 -2
  47. package/src/configuration.js +61 -3
  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
@@ -6,7 +6,6 @@ import FrontendModelBaseResource from "../frontend-model-resource/base-resource.
6
6
  import SyncEnvelopeReplayService from "./sync-envelope-replay-service.js"
7
7
  import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
8
8
  import {syncUpstreamImporterForConfiguration} from "./sync-upstream-importer.js"
9
- import VelociousError from "../velocious-error.js"
10
9
 
11
10
  /**
12
11
  * Optional client-declared sync scope carried on a changes request.
@@ -26,8 +25,6 @@ import VelociousError from "../velocious-error.js"
26
25
  * `resourceId`/`resourceType` keep working unchanged.
27
26
  * @typedef {Record<string, ReturnType<typeof JSON.parse>> & {resourceId: string, resourceType: string}} ChangeDeliverableSyncEntry
28
27
  */
29
- const QUICK_SEARCH_COLUMN = "quickSearch"
30
-
31
28
  /**
32
29
  * Base resource for Velocious sync endpoints.
33
30
  *
@@ -49,61 +46,21 @@ export default class SyncResourceBase extends FrontendModelBaseResource {
49
46
  static ReplayServiceClass = undefined
50
47
 
51
48
  /**
52
- * Declarative quick-search text columns. When declared, an index search on
53
- * the pseudo-column `quickSearch` expands to an OR of LIKE conditions over
54
- * these root-table columns instead of hitting the controller default.
55
- * @type {string[] | null} */
56
- static quickSearchColumns = null
57
-
58
- /**
59
- * Applies frontend-model index searches, expanding declared quick searches.
60
- * @param {object} args - Search args.
61
- * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceController} args.controller - Controller handling the query.
62
- * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceAnyQuery} args.query - Query instance.
63
- * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceSearch} args.search - Search params.
64
- * @returns {void}
49
+ * Keeps the sync resource's established public error contract for invalid
50
+ * quick-search payloads; the quick-search mechanism itself lives on
51
+ * FrontendModelBaseResource.
52
+ * @returns {string} - Error label.
65
53
  */
66
- applyFrontendModelIndexSearch({controller, query, search}) {
67
- if (this.applyQuickSearch({query, search})) return
68
-
69
- super.applyFrontendModelIndexSearch({controller, query, search})
54
+ quickSearchErrorLabel() {
55
+ return "Sync quick search"
70
56
  }
71
57
 
72
58
  /**
73
- * Expands a `quickSearch` pseudo-column search into an OR of LIKE conditions
74
- * over the declared {@link SyncResourceBase.quickSearchColumns}.
75
- * @param {object} args - Search args.
76
- * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceAnyQuery} args.query - Query to filter.
77
- * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceSearch} args.search - Search payload.
78
- * @returns {boolean} Whether the search was handled as a quick search.
59
+ * Keeps the established public error code for invalid quick-search payloads.
60
+ * @returns {string} - Error code.
79
61
  */
80
- applyQuickSearch({query, search}) {
81
- const quickSearchColumns = /** @type {typeof SyncResourceBase} */ (this.constructor).quickSearchColumns
82
-
83
- if (!quickSearchColumns || quickSearchColumns.length === 0) return false
84
- if (search.path.length > 0 || search.column !== QUICK_SEARCH_COLUMN) return false
85
-
86
- if (search.operator !== "like") {
87
- throw VelociousError.safe("Sync quick search must use the like operator.", {code: "sync-invalid-quick-search"})
88
- }
89
-
90
- if (typeof search.value !== "string") {
91
- throw VelociousError.safe("Sync quick search must be a string.", {code: "sync-invalid-quick-search"})
92
- }
93
-
94
- const trimmedValue = search.value.trim()
95
-
96
- if (!trimmedValue) return true
97
-
98
- const tableSql = query.driver.quoteTable(query.getTableReferenceForJoin())
99
- const likeValue = `%${trimmedValue}%`
100
- const conditions = quickSearchColumns.map((columnName) => (
101
- `${tableSql}.${query.driver.quoteColumn(columnName)} LIKE ${query.driver.quote(likeValue)}`
102
- ))
103
-
104
- query.where(`(${conditions.join(" OR ")})`)
105
-
106
- return true
62
+ quickSearchErrorCode() {
63
+ return "sync-invalid-quick-search"
107
64
  }
108
65
 
109
66
  // The declarative `static writableAttributes` permit list lives on
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.701",
6
+ "version": "1.0.702",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -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
  */
@@ -900,7 +900,7 @@ export default class VelociousConfiguration {
900
900
  /**
901
901
  * Normalizes client-side sync configuration consumed by `SyncClient.fromConfiguration(...)`.
902
902
  * @param {import("./configuration-types.js").VelociousSyncClientConfiguration | undefined} client - Client-side sync configuration.
903
- * @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.
904
904
  */
905
905
  _normalizeSyncClientConfiguration(client) {
906
906
  if (client === undefined || client === null) return undefined
@@ -909,11 +909,11 @@ export default class VelociousConfiguration {
909
909
  throw new Error("sync.client must be an object with transport and authenticationToken")
910
910
  }
911
911
 
912
- 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
913
913
  const restClientKeys = Object.keys(restClient)
914
914
 
915
915
  if (restClientKeys.length > 0) {
916
- 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)`)
917
917
  }
918
918
  if (!transport || typeof transport !== "object" || typeof transport.post !== "function") {
919
919
  throw new Error("sync.client.transport must be an object with a post(path, body) method (like the frontend-model websocket client)")
@@ -945,6 +945,7 @@ export default class VelociousConfiguration {
945
945
  batchSize,
946
946
  isOnline,
947
947
  mountPath: (mountPath || "/velocious/sync").replace(/\/+$/u, "") || "/",
948
+ offline: this._normalizeSyncClientOfflineConfiguration(offline),
948
949
  onError,
949
950
  realtime,
950
951
  transport,
@@ -953,6 +954,63 @@ export default class VelociousConfiguration {
953
954
  }
954
955
  }
955
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
+
956
1014
  /**
957
1015
  * Normalizes sync API endpoint configuration.
958
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.
@@ -173,11 +173,17 @@ export default class LocalMutationLog {
173
173
 
174
174
  /**
175
175
  * Replaces the mutation payload of a still-pending record. Used when an
176
- * acknowledged predecessor supplies the authoritative base for its successor.
177
- * @param {{id: string, mutation: import("./device-identity.js").SyncMutation}} args - Record and replacement mutation.
176
+ * acknowledged predecessor supplies the authoritative base for its successor
177
+ * and when an offline session re-signs a pending record under a refreshed
178
+ * grant (pass the new `signedMutation` envelope together with the mutation).
179
+ * @param {{id: string, mutation: import("./device-identity.js").SyncMutation, signedMutation?: import("./device-identity.js").SignedSyncMutation}} args - Record and replacement mutation.
178
180
  * @returns {Promise<LocalMutationLogRecord>} Updated record.
179
181
  */
180
- async updateMutation({id, mutation}) {
182
+ async updateMutation({id, mutation, signedMutation}) {
183
+ if (signedMutation !== undefined && stableJsonStringify(signedMutation.mutation) !== stableJsonStringify(mutation)) {
184
+ throw new Error("signedMutation must match the mutation payload it signs")
185
+ }
186
+
181
187
  return await withStorageKeyLock(this.storageKey, async () => {
182
188
  const rawRecord = await this.storage.record(this.storageKey, id)
183
189
 
@@ -188,6 +194,7 @@ export default class LocalMutationLog {
188
194
  if (!PENDING_STATUSES.has(record.status)) throw new Error(`Cannot update mutation for ${record.status} local mutation '${id}'`)
189
195
 
190
196
  record.mutation = normalizeMutation(mutation)
197
+ if (signedMutation !== undefined) record.signedMutation = /** @type {import("./device-identity.js").SignedSyncMutation} */ (JSON.parse(JSON.stringify(signedMutation)))
191
198
  record.updatedAt = this.currentTimestamp()
192
199
  await this.storage.updateRecord(this.storageKey, cloneRecord(record))
193
200
 
@@ -11,6 +11,12 @@ import {createSignedMutation, mutationIdempotencyKey, verifySignedMutation} from
11
11
  */
12
12
  /**
13
13
  * One peer mutation bundle entry.
14
+ * @typedef {object} PeerMutationBundleImportResult
15
+ * @property {Array<{clientMutationId: string, idempotencyKey: string, localRecordId: string}>} imported - Entries appended to the local log.
16
+ * @property {Array<{errorMessage: string, index: number}>} rejected - Entries that failed verification.
17
+ * @property {Array<{clientMutationId: string, idempotencyKey: string, localRecordId: string, reason: "duplicate"}>} skipped - Entries already present by idempotency key.
18
+ */
19
+ /**
14
20
  * @typedef {object} PeerMutationBundleEntry
15
21
  * @property {string} [localRecordId] - Exporting device's local mutation record id.
16
22
  * @property {number} [localSequence] - Exporting device's local mutation sequence.