@rebasepro/server 0.24.0 → 0.24.1-canary.gcb918fc
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.
- package/README.md +27 -2
- package/dist/GCSStorageController-fv6JAZ87.js +402 -0
- package/dist/GCSStorageController-fv6JAZ87.js.map +1 -0
- package/dist/S3StorageController-BNg-pXiY.js +480 -0
- package/dist/S3StorageController-BNg-pXiY.js.map +1 -0
- package/dist/api/mount-path.d.ts +30 -0
- package/dist/api/mount.d.ts +6 -13
- package/dist/api/rest/api-generator.d.ts +53 -1
- package/dist/api/rest/auth-collection-writes.d.ts +28 -21
- package/dist/api/rest/field-access-query.d.ts +28 -8
- package/dist/api/rest/field-ops.d.ts +19 -7
- package/dist/api/rest/query-body.d.ts +10 -0
- package/dist/api/rest/write-validation.d.ts +7 -5
- package/dist/api/schema-editor-routes.d.ts +10 -1
- package/dist/api/sql-export-routes.d.ts +8 -0
- package/dist/api/types.d.ts +35 -0
- package/dist/{ast-schema-editor-CWqS_sLJ.js → ast-schema-editor-mCeSC03x.js} +3 -3
- package/dist/{ast-schema-editor-CWqS_sLJ.js.map → ast-schema-editor-mCeSC03x.js.map} +1 -1
- package/dist/auth/access.d.ts +57 -8
- package/dist/auth/admin-user-ops.d.ts +24 -2
- package/dist/auth/admin-users-route.d.ts +1 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +10 -3
- package/dist/auth/api-keys/api-key-routes.d.ts +4 -1
- package/dist/auth/builtin-auth-adapter.d.ts +13 -0
- package/dist/auth/email-change-routes.d.ts +3 -0
- package/dist/auth/expired-token-sweep.d.ts +5 -1
- package/dist/auth/interfaces.d.ts +10 -0
- package/dist/auth/jwks-routes.d.ts +5 -1
- package/dist/auth/jwt-crypto.d.ts +7 -0
- package/dist/auth/jwt-keys.d.ts +4 -0
- package/dist/auth/jwt.d.ts +282 -166
- package/dist/auth/magic-link-routes.d.ts +3 -0
- package/dist/auth/mfa-crypto.d.ts +9 -3
- package/dist/auth/mfa-gate.d.ts +4 -2
- package/dist/auth/mfa-routes.d.ts +6 -0
- package/dist/auth/middleware.d.ts +9 -2
- package/dist/auth/otp-routes.d.ts +3 -0
- package/dist/auth/rate-limiter.d.ts +61 -34
- package/dist/auth/registration-policy.d.ts +17 -0
- package/dist/auth/require-auth.d.ts +1 -1
- package/dist/auth/routes.d.ts +16 -0
- package/dist/auth/session-routes.d.ts +6 -0
- package/dist/auth/user-picking.d.ts +37 -0
- package/dist/auth/verify-credential.d.ts +25 -4
- package/dist/{auth-DMLngxn_.js → auth-GnONzteu.js} +2869 -1871
- package/dist/auth-GnONzteu.js.map +1 -0
- package/dist/auth_adapter-CIItCTc8.js +21 -0
- package/dist/auth_adapter-CIItCTc8.js.map +1 -0
- package/dist/backend/create-backend.d.ts +281 -0
- package/dist/backend/default-backend.d.ts +35 -0
- package/dist/backend/options.d.ts +912 -0
- package/dist/backend/scope.d.ts +23 -0
- package/dist/backend/services.d.ts +54 -0
- package/dist/backend-DTAOsLQc.js.map +1 -1
- package/dist/backup/backup-common.d.ts +14 -3
- package/dist/backup/backup-routes.d.ts +23 -2
- package/dist/backup/backup-storage.d.ts +3 -1
- package/dist/backup/index.d.ts +3 -1
- package/dist/{backup-CN0s50D2.js → backup-BnvkSLHi.js} +176 -35
- package/dist/backup-BnvkSLHi.js.map +1 -0
- package/dist/boot/boot.d.ts +2 -2
- package/dist/boot/driver.d.ts +3 -0
- package/dist/boot/env.d.ts +9 -2
- package/dist/boot/http-server.d.ts +22 -0
- package/dist/boot/options.d.ts +16 -1
- package/dist/boot/provision.d.ts +4 -2
- package/dist/boot/runtime-cors.d.ts +17 -0
- package/dist/boot/sources.d.ts +2 -0
- package/dist/boot/static-routing.d.ts +9 -0
- package/dist/{contract-routes-fz8i4pxs.js → contract-routes-Ey6ftnKH.js} +4 -20
- package/dist/contract-routes-Ey6ftnKH.js.map +1 -0
- package/dist/cron/cron-loader.d.ts +77 -18
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +69 -3
- package/dist/cron/cron-store.d.ts +33 -5
- package/dist/cron/define-cron.d.ts +6 -2
- package/dist/cron/index.d.ts +3 -3
- package/dist/cron-loader-B-u0snqq.js +202 -0
- package/dist/cron-loader-B-u0snqq.js.map +1 -0
- package/dist/{cron-routes-Bc-SB0Se.js → cron-routes-OD6BstUj.js} +27 -4
- package/dist/cron-routes-OD6BstUj.js.map +1 -0
- package/dist/{cron-scheduler-CYQgco86.js → cron-scheduler-idtLPoN-.js} +299 -33
- package/dist/cron-scheduler-idtLPoN-.js.map +1 -0
- package/dist/cron-store-CZ30ZBi-.js +577 -0
- package/dist/cron-store-CZ30ZBi-.js.map +1 -0
- package/dist/email/smtp-email-service.d.ts +23 -0
- package/dist/email/types.d.ts +18 -0
- package/dist/env.d.ts +0 -37
- package/dist/filter-dialect-O1HK6Nvr.js +1628 -0
- package/dist/filter-dialect-O1HK6Nvr.js.map +1 -0
- package/dist/{function-loader-D7o5Epjj.js → function-loader-iKhOZatS.js} +68 -38
- package/dist/function-loader-iKhOZatS.js.map +1 -0
- package/dist/{function-routes-CaNG4waN.js → function-routes-j9TwpvQe.js} +5 -4
- package/dist/function-routes-j9TwpvQe.js.map +1 -0
- package/dist/functions/context.d.ts +9 -0
- package/dist/functions/define-function.d.ts +10 -6
- package/dist/functions/function-loader.d.ts +75 -18
- package/dist/functions/function-routes.d.ts +2 -1
- package/dist/functions/index.d.ts +5 -4
- package/dist/functions/index.js +42 -13
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/internal.d.ts +2 -2
- package/dist/functions/proxy.d.ts +6 -2
- package/dist/functions/request-timeout.d.ts +7 -5
- package/dist/history/history-routes.d.ts +9 -0
- package/dist/history/retention-sweep.d.ts +81 -0
- package/dist/{history-recorder-Nr8zLvoU.js → history-recorder-BfWRwmPy.js} +3 -3
- package/dist/{history-recorder-Nr8zLvoU.js.map → history-recorder-BfWRwmPy.js.map} +1 -1
- package/dist/{history-store-rcAm_xFR.js → history-store-DPofGVDp.js} +2 -2
- package/dist/{history-store-rcAm_xFR.js.map → history-store-DPofGVDp.js.map} +1 -1
- package/dist/index.d.ts +23 -10
- package/dist/index.es.js +24423 -20038
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +27 -10
- package/dist/init/process-safety.d.ts +7 -2
- package/dist/init/shutdown.d.ts +18 -3
- package/dist/init.d.ts +49 -865
- package/dist/internal-tables-L2t39RdT.js +136 -0
- package/dist/internal-tables-L2t39RdT.js.map +1 -0
- package/dist/jobs/job-queue.d.ts +8 -0
- package/dist/jobs/job-store.d.ts +24 -0
- package/dist/jobs/types.d.ts +25 -4
- package/dist/{jobs-DqYNfquG.js → jobs-BCbflZj2.js} +234 -24
- package/dist/jobs-BCbflZj2.js.map +1 -0
- package/dist/{jwt-R6bSPMjk.js → jwt-BlvuKoR6.js} +529 -454
- package/dist/jwt-BlvuKoR6.js.map +1 -0
- package/dist/{keys-GAVZqbqx.js → keys-COzQ2aE8.js} +2 -2
- package/dist/{keys-GAVZqbqx.js.map → keys-COzQ2aE8.js.map} +1 -1
- package/dist/load-failure-L5hvfDoG.js +80 -0
- package/dist/load-failure-L5hvfDoG.js.map +1 -0
- package/dist/mcp/mcp-routes.d.ts +6 -0
- package/dist/mcp/mcp-tools.d.ts +8 -0
- package/dist/mcp/oauth-routes.d.ts +7 -0
- package/dist/metrics/index.d.ts +18 -0
- package/dist/metrics/pool-wait.d.ts +50 -0
- package/dist/offline-QNQgsPOq-B2yGeXkn.js +3305 -0
- package/dist/offline-QNQgsPOq-B2yGeXkn.js.map +1 -0
- package/dist/offline-store-Cm5sAX81-CALiO4Gn.js +1915 -0
- package/dist/offline-store-Cm5sAX81-CALiO4Gn.js.map +1 -0
- package/dist/{openapi-generator-DAq_XVDu.js → openapi-generator-D9zUQmEp.js} +366 -82
- package/dist/openapi-generator-D9zUQmEp.js.map +1 -0
- package/dist/policy-PdzX-Opd.js +121 -0
- package/dist/policy-PdzX-Opd.js.map +1 -0
- package/dist/project_manifest-R6y_KHms.js +59 -0
- package/dist/project_manifest-R6y_KHms.js.map +1 -0
- package/dist/{proxy-qRlqeUmO.js → proxy-CkTlMGmh.js} +4 -3
- package/dist/proxy-CkTlMGmh.js.map +1 -0
- package/dist/{query-parser-BgiKJKvc.js → query-parser-4WkMoA_K.js} +510 -15
- package/dist/query-parser-4WkMoA_K.js.map +1 -0
- package/dist/{request-timeout-DgH7j8qO.js → request-timeout-Dnl2_OBc.js} +15 -8
- package/dist/request-timeout-Dnl2_OBc.js.map +1 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js +24 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js.map +1 -0
- package/dist/schema-edit/apply-schema-change.d.ts +10 -0
- package/dist/schema-edit/local-source.d.ts +29 -0
- package/dist/schema-edit/remote-source.d.ts +13 -3
- package/dist/schema-edit/source-editor.d.ts +28 -0
- package/dist/{schema-editor-routes-oIyuWl3L.js → schema-editor-routes-C11Tumyd.js} +54 -10
- package/dist/schema-editor-routes-C11Tumyd.js.map +1 -0
- package/dist/services/outbound-post.d.ts +50 -0
- package/dist/services/outbound-url-guard.d.ts +34 -5
- package/dist/services/routed-realtime-service.d.ts +5 -0
- package/dist/services/webhook-service.d.ts +66 -14
- package/dist/singleton.d.ts +9 -5
- package/dist/soft-delete-params-DfV3vVN5.js +668 -0
- package/dist/soft-delete-params-DfV3vVN5.js.map +1 -0
- package/dist/sql-export-routes-ClRwzIsC.js +240 -0
- package/dist/sql-export-routes-ClRwzIsC.js.map +1 -0
- package/dist/{src-CatHFUym.js → src-D5Y3N-S4.js} +2 -1
- package/dist/{src-CatHFUym.js.map → src-D5Y3N-S4.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +46 -2
- package/dist/storage/LocalStorageController.d.ts +67 -2
- package/dist/storage/S3StorageController.d.ts +37 -3
- package/dist/storage/image-transform.d.ts +5 -0
- package/dist/storage/maintenance-routes.d.ts +23 -0
- package/dist/storage/maintenance.d.ts +126 -0
- package/dist/storage/rendition-cache.d.ts +9 -11
- package/dist/storage/request-principals.d.ts +30 -0
- package/dist/storage/routes.d.ts +20 -0
- package/dist/storage/source-variables.d.ts +10 -0
- package/dist/storage/streams.d.ts +42 -0
- package/dist/storage/tus-handler.d.ts +46 -2
- package/dist/storage/types.d.ts +82 -7
- package/dist/storage/upload-validation.d.ts +13 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js +125 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js.map +1 -0
- package/dist/strings.es-BeZWAodn.js +256 -0
- package/dist/strings.es-BeZWAodn.js.map +1 -0
- package/dist/upload-validation-Dd-fnsbA.js +172 -0
- package/dist/upload-validation-Dd-fnsbA.js.map +1 -0
- package/dist/utils/byte-size.d.ts +10 -0
- package/dist/utils/compression.d.ts +6 -0
- package/dist/utils/load-failure.d.ts +14 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-BSiP1c-f.js +0 -256
- package/dist/GCSStorageController-BSiP1c-f.js.map +0 -1
- package/dist/S3StorageController-CAwFRgjV.js +0 -276
- package/dist/S3StorageController-CAwFRgjV.js.map +0 -1
- package/dist/auth-DMLngxn_.js.map +0 -1
- package/dist/backup-CN0s50D2.js.map +0 -1
- package/dist/contract-routes-fz8i4pxs.js.map +0 -1
- package/dist/cron-loader-CwaANlOG.js +0 -118
- package/dist/cron-loader-CwaANlOG.js.map +0 -1
- package/dist/cron-routes-Bc-SB0Se.js.map +0 -1
- package/dist/cron-scheduler-CYQgco86.js.map +0 -1
- package/dist/cron-store-D2Q9-Aco.js +0 -300
- package/dist/cron-store-D2Q9-Aco.js.map +0 -1
- package/dist/dynamic-import-X40pTZUQ.js +0 -23
- package/dist/dynamic-import-X40pTZUQ.js.map +0 -1
- package/dist/function-loader-D7o5Epjj.js.map +0 -1
- package/dist/function-routes-CaNG4waN.js.map +0 -1
- package/dist/jobs-DqYNfquG.js.map +0 -1
- package/dist/jwt-R6bSPMjk.js.map +0 -1
- package/dist/openapi-generator-DAq_XVDu.js.map +0 -1
- package/dist/proxy-qRlqeUmO.js.map +0 -1
- package/dist/query-parser-BgiKJKvc.js.map +0 -1
- package/dist/request-timeout-DgH7j8qO.js.map +0 -1
- package/dist/schema-editor-routes-oIyuWl3L.js.map +0 -1
- package/dist/soft-delete-params-BWPilMPF.js +0 -59
- package/dist/soft-delete-params-BWPilMPF.js.map +0 -1
- package/dist/src-I3aG1PcY.js +0 -6870
- package/dist/src-I3aG1PcY.js.map +0 -1
- package/dist/types-BfKcm9do.js +0 -39
- package/dist/types-BfKcm9do.js.map +0 -1
|
@@ -0,0 +1,1628 @@
|
|
|
1
|
+
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
+
import __rebaseProcess from "process";
|
|
3
|
+
globalThis.process ??= __rebaseProcess;
|
|
4
|
+
__rebaseCreateRequire(import.meta.url);
|
|
5
|
+
import { c as RebaseClientError } from "./strings.es-BeZWAodn.js";
|
|
6
|
+
import { J as CANONICAL_TO_REST, Q as isRelationAggregateSort, X as NULL_OPS, Y as LIST_OPS, Z as REST_TO_CANONICAL, et as sortKeyToString, q as ALL_WHERE_FILTER_OPS, tt as toCanonicalOp } from "./src-D5Y3N-S4.js";
|
|
7
|
+
//#region ../types/src/types/entities.ts
|
|
8
|
+
/**
|
|
9
|
+
* Class used to create a reference to a entity in a different path.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Simple reference (most common case - single driver, single db)
|
|
13
|
+
* new EntityReference({ id: "123", path: "users" })
|
|
14
|
+
*
|
|
15
|
+
* // Reference to a different driver (e.g., Firestore)
|
|
16
|
+
* new EntityReference({ id: "123", path: "analytics", driver: "firestore" })
|
|
17
|
+
*
|
|
18
|
+
* // Reference to a specific database within a driver
|
|
19
|
+
* new EntityReference({ id: "123", path: "orders", driver: "postgres", databaseId: "orders_db" })
|
|
20
|
+
*/
|
|
21
|
+
var EntityReference = class {
|
|
22
|
+
__type = "reference";
|
|
23
|
+
/**
|
|
24
|
+
* ID of the entity
|
|
25
|
+
*/
|
|
26
|
+
id;
|
|
27
|
+
/**
|
|
28
|
+
* A string representing the path of the referenced document (relative
|
|
29
|
+
* to the root of the database).
|
|
30
|
+
*/
|
|
31
|
+
path;
|
|
32
|
+
/**
|
|
33
|
+
* Which driver (e.g., 'postgres', 'firestore').
|
|
34
|
+
* Defaults to "(default)" if not specified.
|
|
35
|
+
*/
|
|
36
|
+
driver;
|
|
37
|
+
/**
|
|
38
|
+
* Which database within the driver.
|
|
39
|
+
* Defaults to "(default)" if not specified.
|
|
40
|
+
*/
|
|
41
|
+
databaseId;
|
|
42
|
+
/**
|
|
43
|
+
* Create a reference to a entity.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Simple reference (most common case)
|
|
47
|
+
* new EntityReference({ id: "123", path: "users" })
|
|
48
|
+
*
|
|
49
|
+
* // With driver
|
|
50
|
+
* new EntityReference({ id: "123", path: "analytics", driver: "firestore" })
|
|
51
|
+
*/
|
|
52
|
+
constructor(props) {
|
|
53
|
+
this.id = props.id;
|
|
54
|
+
this.path = props.path;
|
|
55
|
+
this.driver = props.driver;
|
|
56
|
+
this.databaseId = props.databaseId;
|
|
57
|
+
}
|
|
58
|
+
get pathWithId() {
|
|
59
|
+
return `${this.path}/${this.id}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Get the full path including driver and database prefixes if specified.
|
|
63
|
+
* For the common case (single driver, single db), this just returns pathWithId.
|
|
64
|
+
*/
|
|
65
|
+
get fullPath() {
|
|
66
|
+
const parts = [];
|
|
67
|
+
if (this.driver && this.driver !== "(default)") parts.push(this.driver);
|
|
68
|
+
if (this.databaseId && this.databaseId !== "(default)") parts.push(this.databaseId);
|
|
69
|
+
if (parts.length > 0) return `${parts.join(":")}:::${this.path}/${this.id}`;
|
|
70
|
+
return this.pathWithId;
|
|
71
|
+
}
|
|
72
|
+
isEntityReference() {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Class used to create a reference to a entity in a different path
|
|
78
|
+
*/
|
|
79
|
+
var EntityRelation = class {
|
|
80
|
+
__type = "relation";
|
|
81
|
+
/**
|
|
82
|
+
* ID of the entity
|
|
83
|
+
*/
|
|
84
|
+
id;
|
|
85
|
+
/**
|
|
86
|
+
* A string representing the path of the referenced document (relative
|
|
87
|
+
* to the root of the database).
|
|
88
|
+
*/
|
|
89
|
+
path;
|
|
90
|
+
/**
|
|
91
|
+
* Pre-fetched data payload to eliminate N+1 queries.
|
|
92
|
+
* When present, clients can use this directly instead of fetching.
|
|
93
|
+
*/
|
|
94
|
+
data;
|
|
95
|
+
constructor(id, path, data) {
|
|
96
|
+
this.id = id;
|
|
97
|
+
this.path = path;
|
|
98
|
+
this.data = data;
|
|
99
|
+
}
|
|
100
|
+
get pathWithId() {
|
|
101
|
+
return `${this.path}/${this.id}`;
|
|
102
|
+
}
|
|
103
|
+
isEntityReference() {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
isEntityRelation() {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
var GeoPoint = class {
|
|
111
|
+
/**
|
|
112
|
+
* The latitude of this GeoPoint instance.
|
|
113
|
+
*/
|
|
114
|
+
latitude;
|
|
115
|
+
/**
|
|
116
|
+
* The longitude of this GeoPoint instance.
|
|
117
|
+
*/
|
|
118
|
+
longitude;
|
|
119
|
+
constructor(latitude, longitude) {
|
|
120
|
+
this.latitude = latitude;
|
|
121
|
+
this.longitude = longitude;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var Vector = class {
|
|
125
|
+
value;
|
|
126
|
+
constructor(value) {
|
|
127
|
+
this.value = value;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region ../types/src/controllers/data.ts
|
|
132
|
+
/**
|
|
133
|
+
* SDK collection client — returns flat rows, no Entity wrapper.
|
|
134
|
+
*
|
|
135
|
+
* This is the public API surface for app developers using
|
|
136
|
+
* `createRebaseClient()`. admin internals use `CollectionAccessor` instead.
|
|
137
|
+
*
|
|
138
|
+
* Type parameters:
|
|
139
|
+
* - `M` — the **Row** shape returned by reads (`find`, `findById`, `listen`).
|
|
140
|
+
* - `I` — the **Insert** shape accepted by {@link create}. Defaults to
|
|
141
|
+
* `Partial<M>`; the generated SDK supplies a dedicated `Insert` type where
|
|
142
|
+
* required columns are required and auto-generated / read-only columns are
|
|
143
|
+
* omitted, so `create({})` on a table with required fields is a compile error.
|
|
144
|
+
* - `U` — the **Update** shape accepted by {@link update}. Defaults to
|
|
145
|
+
* `Partial<M>`; the generated SDK supplies a dedicated `Update` type.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* const { data: posts } = await rebase.data.posts.find();
|
|
149
|
+
* console.log(posts[0].title); // flat access
|
|
150
|
+
* console.log(posts[0].id); // id at top level
|
|
151
|
+
*
|
|
152
|
+
* const post = await rebase.data.posts.findById(1);
|
|
153
|
+
* console.log(post?.title); // no .values needed
|
|
154
|
+
*
|
|
155
|
+
* @group Data
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* A change expressed as an operation on the column's current value, rather than
|
|
159
|
+
* as the value to store.
|
|
160
|
+
*
|
|
161
|
+
* `{ views: 5 }` says what the number becomes; `{ views: { $inc: 1 } }` says
|
|
162
|
+
* what happens to it. The difference is the read the caller no longer has to
|
|
163
|
+
* make — and the race that read opens. Two requests that each read `4`, add one
|
|
164
|
+
* and write `5` lose an increment between them; `SET views = views + 1` cannot,
|
|
165
|
+
* because the arithmetic happens inside the statement holding the row lock.
|
|
166
|
+
*
|
|
167
|
+
* Exactly one operator per field. `{ views: { $inc: 1, $push: "x" } }` is
|
|
168
|
+
* refused rather than applied in an order the caller cannot see.
|
|
169
|
+
*
|
|
170
|
+
* @group Data
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* The operator names, as a value.
|
|
174
|
+
*
|
|
175
|
+
* A runtime list beside the type because three layers have to *recognise* an
|
|
176
|
+
* operation, not just accept one: the REST validator, the driver that compiles
|
|
177
|
+
* it, and the offline queue that must refuse to apply one locally. Three copies
|
|
178
|
+
* of four strings is three chances for one of them to miss an operator added to
|
|
179
|
+
* the other two, and the failure is silent in the worst direction — an
|
|
180
|
+
* unrecognised marker is written to the column as a JSON document.
|
|
181
|
+
*
|
|
182
|
+
* @group Data
|
|
183
|
+
*/
|
|
184
|
+
var FIELD_OPERATORS = [
|
|
185
|
+
"$inc",
|
|
186
|
+
"$push",
|
|
187
|
+
"$pull",
|
|
188
|
+
"$merge"
|
|
189
|
+
];
|
|
190
|
+
/**
|
|
191
|
+
* The key of a {@link BatchRef}. Declared here, beside the field operators,
|
|
192
|
+
* because the two share one namespace: a `$`-prefixed key in a write payload is
|
|
193
|
+
* a marker, and every reader of that namespace has to know all of it.
|
|
194
|
+
*
|
|
195
|
+
* @group Data
|
|
196
|
+
*/
|
|
197
|
+
var BATCH_REF_KEY = "$ref";
|
|
198
|
+
/**
|
|
199
|
+
* Whether a value is *trying* to be a field operation — including a misspelled
|
|
200
|
+
* one, which is the case worth catching.
|
|
201
|
+
*
|
|
202
|
+
* Any `$`-prefixed key counts, because `{ $increment: 1 }` written to a number
|
|
203
|
+
* column as a JSON document is the failure this exists to prevent.
|
|
204
|
+
*
|
|
205
|
+
* Except on a `map` (pass `propertyType`): a map's value is a JSON document,
|
|
206
|
+
* and one may legitimately carry `$` keys — a JSON Schema's `$schema`, a
|
|
207
|
+
* MongoDB-style export. There, only a single-key object whose key is one of
|
|
208
|
+
* {@link FIELD_OPERATORS} is an operation, and anything else is data. A caller
|
|
209
|
+
* that does not know the property type gets the strict reading.
|
|
210
|
+
*
|
|
211
|
+
* The one exception is `{ $ref: … }`, the batch's backward reference. It stands
|
|
212
|
+
* where a *value* goes and is resolved to one before the row is written, so it
|
|
213
|
+
* is not an operation on a column — reading it as a misspelled operator refused
|
|
214
|
+
* every `$ref` in a batch with "unknown field operator '$ref'".
|
|
215
|
+
*
|
|
216
|
+
* @group Data
|
|
217
|
+
*/
|
|
218
|
+
function isFieldOperation(value, propertyType) {
|
|
219
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date) return false;
|
|
220
|
+
const keys = Object.keys(value);
|
|
221
|
+
if (keys.length === 1 && keys[0] === "$ref") return false;
|
|
222
|
+
if (propertyType === "map") return keys.length === 1 && FIELD_OPERATORS.includes(keys[0]);
|
|
223
|
+
return keys.some((key) => key.startsWith("$"));
|
|
224
|
+
}
|
|
225
|
+
/** True when any value in a write payload is (or is attempting to be) one. @group Data */
|
|
226
|
+
function hasFieldOperation(values) {
|
|
227
|
+
return !!values && Object.values(values).some((value) => isFieldOperation(value));
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region ../common/src/util/entities.ts
|
|
231
|
+
/**
|
|
232
|
+
* Normalize a value into a proper EntityRelation instance.
|
|
233
|
+
* Handles EntityRelation class instances, and plain objects
|
|
234
|
+
* with `__type === "relation"` or an `isEntityRelation()` method.
|
|
235
|
+
*
|
|
236
|
+
* When `propertyType` is `"relation"`, also accepts plain objects that
|
|
237
|
+
* have `id` and `path` fields — these are relation-shaped objects from
|
|
238
|
+
* edge cases in the data pipeline (REST fallback, stale cache, custom data source).
|
|
239
|
+
*
|
|
240
|
+
* When `targetPath` is given, also accepts a bare id. A relation column is a
|
|
241
|
+
* foreign key, and the REST layer returns it as the scalar it is; only some
|
|
242
|
+
* fetch paths hydrate it into an object. Which form a caller sees therefore
|
|
243
|
+
* depends on how the row was loaded, and a caller that only accepted objects
|
|
244
|
+
* reported half of its own data as a type error. The declared target is the
|
|
245
|
+
* missing half: with it, an id is a relation that has not been fetched yet.
|
|
246
|
+
*
|
|
247
|
+
* Returns null if the value cannot be coerced.
|
|
248
|
+
*/
|
|
249
|
+
function normalizeToEntityRelation(value, propertyType, targetPath) {
|
|
250
|
+
if (value instanceof EntityRelation) return value;
|
|
251
|
+
if (targetPath && (typeof value === "string" || typeof value === "number")) {
|
|
252
|
+
if (value === "") return null;
|
|
253
|
+
return new EntityRelation(value, targetPath);
|
|
254
|
+
}
|
|
255
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
256
|
+
const obj = value;
|
|
257
|
+
if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
|
|
258
|
+
return new EntityRelation(obj.id, obj.path, obj.data);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* A row snapshot with every relation in it reduced to its reference — target
|
|
262
|
+
* path and id, `__type` kept — and the related row's values dropped.
|
|
263
|
+
*
|
|
264
|
+
* What a history entry stores and serves. The history route is gated on
|
|
265
|
+
* reading the row, not the rows it points at, so a snapshot that embedded the
|
|
266
|
+
* admin's `{ __type: "relation", data: { values } }` envelope handed whoever
|
|
267
|
+
* may read `posts` history the `authors` columns too. Entries are written as
|
|
268
|
+
* the stored row now, which holds no envelope; this keeps it that way for any
|
|
269
|
+
* path that hands one in, and reads entries recorded before.
|
|
270
|
+
*
|
|
271
|
+
* Walks maps and arrays, since a relation can sit inside either. Anything that
|
|
272
|
+
* is not an envelope comes back as it was.
|
|
273
|
+
*/
|
|
274
|
+
function withRelationReferencesOnly(value) {
|
|
275
|
+
if (Array.isArray(value)) return value.map((item) => withRelationReferencesOnly(item));
|
|
276
|
+
if (value === null || typeof value !== "object" || value instanceof Date) return value;
|
|
277
|
+
const obj = value;
|
|
278
|
+
if (obj.__type === "relation" || obj.__type === "reference") {
|
|
279
|
+
const { data: _related, ...reference } = obj;
|
|
280
|
+
return reference;
|
|
281
|
+
}
|
|
282
|
+
if (Object.getPrototypeOf(obj) !== Object.prototype && Object.getPrototypeOf(obj) !== null) return value;
|
|
283
|
+
return Object.fromEntries(Object.entries(obj).map(([key, item]) => [key, withRelationReferencesOnly(item)]));
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region ../common/src/data/sort-dialect.ts
|
|
287
|
+
/**
|
|
288
|
+
* Sort-order wire codec.
|
|
289
|
+
*
|
|
290
|
+
* This is the ONLY module that knows about the colon-delimited wire format
|
|
291
|
+
* (`"field:direction"`) used in HTTP query parameters, and about the JSON-array
|
|
292
|
+
* form that carries a multi-column sort over the same parameter.
|
|
293
|
+
* Everything else speaks {@link OrderByTuple} exclusively.
|
|
294
|
+
*
|
|
295
|
+
* Mirrors the filter architecture in `filter-dialect.ts`.
|
|
296
|
+
*
|
|
297
|
+
* @module
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* Collapse the one-key and many-key spellings of a sort into the list form.
|
|
301
|
+
*
|
|
302
|
+
* `["a", "desc"]` and `[["a", "desc"]]` mean the same thing and normalize to
|
|
303
|
+
* the same value; the two are told apart by whether the first element is
|
|
304
|
+
* itself an array, which no field name ever is.
|
|
305
|
+
*
|
|
306
|
+
* This is also where a {@link RelationAggregateSort} object stops being an
|
|
307
|
+
* object. Above this function a sort key may be either spelling; below it,
|
|
308
|
+
* every key is a string — which is what `OrderByTuple`, the REST parameter, the
|
|
309
|
+
* driver contract and the cursor all already were. Doing it here means the one
|
|
310
|
+
* place that already collapses the two *shapes* of a sort also collapses the
|
|
311
|
+
* two *spellings* of a key, rather than every consumer learning about both.
|
|
312
|
+
*
|
|
313
|
+
* @returns The keys in order of significance, or `undefined` for no sort. An
|
|
314
|
+
* empty list also returns `undefined` — "sort by nothing" is no sort, and
|
|
315
|
+
* letting `[]` through would have every layer below re-deciding what it meant.
|
|
316
|
+
*/
|
|
317
|
+
function normalizeOrderBy(orderBy) {
|
|
318
|
+
if (!orderBy || orderBy.length === 0) return void 0;
|
|
319
|
+
const list = Array.isArray(orderBy[0]) ? orderBy : [orderBy];
|
|
320
|
+
if (list.length === 0) return void 0;
|
|
321
|
+
return list.map((entry, index) => toStrictTuple(entry, index));
|
|
322
|
+
}
|
|
323
|
+
/** A sort whose *shape* is unusable, as opposed to one naming a field that does not exist. */
|
|
324
|
+
var OrderBySpecError = class extends Error {
|
|
325
|
+
code = "INVALID_ORDER_BY";
|
|
326
|
+
constructor(detail) {
|
|
327
|
+
super(`Invalid \`orderBy\`: ${detail}. Expected a field name, or a list of [field, direction] pairs like [["roles","asc"],["created_at","desc"]]`);
|
|
328
|
+
this.name = "OrderBySpecError";
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/** `first`/`last`, or a refusal naming the entry — see {@link NullsPlacement}. */
|
|
332
|
+
function toStrictNulls(raw, index) {
|
|
333
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
334
|
+
if (raw !== "first" && raw !== "last") throw new OrderBySpecError(`entry ${index} has nulls '${String(raw)}' — expected "first" or "last"`);
|
|
335
|
+
return raw;
|
|
336
|
+
}
|
|
337
|
+
function toStrictTuple(raw, index) {
|
|
338
|
+
if (!Array.isArray(raw)) throw new OrderBySpecError(`entry ${index} has no field name`);
|
|
339
|
+
const key = isRelationAggregateSort(raw[0]) ? sortKeyToString(raw[0]) : raw[0];
|
|
340
|
+
if (typeof key !== "string" || key.trim() === "") throw new OrderBySpecError(`entry ${index} has no field name`);
|
|
341
|
+
const direction = raw[1];
|
|
342
|
+
if (direction !== void 0 && direction !== "asc" && direction !== "desc") throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
|
|
343
|
+
const nulls = toStrictNulls(raw[2], index);
|
|
344
|
+
return nulls ? [
|
|
345
|
+
key,
|
|
346
|
+
direction ?? "asc",
|
|
347
|
+
nulls
|
|
348
|
+
] : [key, direction ?? "asc"];
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Serialize a sort to the wire.
|
|
352
|
+
*
|
|
353
|
+
* A single key keeps the `"field:direction"` shorthand it has always used —
|
|
354
|
+
* short, readable in a URL, and what every existing client and test expects.
|
|
355
|
+
* Several keys are emitted as the canonical JSON array the server already
|
|
356
|
+
* accepts, because the shorthand has no separator to spare: a comma-joined
|
|
357
|
+
* `"a:asc,b:desc"` parses as one field named `a` with the direction
|
|
358
|
+
* `"asc,b:desc"`, which the server refuses.
|
|
359
|
+
*
|
|
360
|
+
* **Runtime tolerance:** if the input is already a well-formed wire string
|
|
361
|
+
* (from an untyped JS caller), it is returned unchanged.
|
|
362
|
+
* This is undocumented tolerance, not public API — don't rely on it.
|
|
363
|
+
*
|
|
364
|
+
* @param orderBy - A canonical tuple or list of tuples, or at runtime
|
|
365
|
+
* possibly a pre-serialized string (undocumented tolerance).
|
|
366
|
+
* @returns The wire-format string, or `undefined` if the input is falsy.
|
|
367
|
+
*
|
|
368
|
+
* @remarks
|
|
369
|
+
* Field names containing `:` are representable in the tuple form but
|
|
370
|
+
* **not** in the single-key wire encoding — this is an inherent limitation of
|
|
371
|
+
* the colon-delimited shorthand and is not resolved here.
|
|
372
|
+
*/
|
|
373
|
+
function serializeOrderBy(orderBy) {
|
|
374
|
+
if (!orderBy) return void 0;
|
|
375
|
+
if (typeof orderBy === "string") return orderBy;
|
|
376
|
+
const list = normalizeOrderBy(orderBy);
|
|
377
|
+
if (!list) return void 0;
|
|
378
|
+
if (list.length === 1) {
|
|
379
|
+
const [field, direction, nulls] = list[0];
|
|
380
|
+
return nulls ? `${field}:${direction}:${nulls}` : `${field}:${direction}`;
|
|
381
|
+
}
|
|
382
|
+
return JSON.stringify(list.map(([field, direction, nulls]) => nulls ? {
|
|
383
|
+
field,
|
|
384
|
+
direction,
|
|
385
|
+
nulls
|
|
386
|
+
} : {
|
|
387
|
+
field,
|
|
388
|
+
direction
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
|
|
393
|
+
*
|
|
394
|
+
* Lenient parsing:
|
|
395
|
+
* - Bare field name (no colon): `"name"` → `["name", "asc"]`
|
|
396
|
+
* - Unknown direction: `"name:foo"` → `["name", "asc"]`
|
|
397
|
+
* - Empty / falsy input, or a blank field name: → `undefined`
|
|
398
|
+
*
|
|
399
|
+
* The leniency is this end's alone; the *server* refuses the same value. This
|
|
400
|
+
* used to say "matches existing server behaviour", and it stopped being true
|
|
401
|
+
* when `parseOrderByParam` grew a strict direction check: `?orderBy=name:foo`
|
|
402
|
+
* now answers `400 INVALID_ORDER_BY` ("entry 0 has direction 'foo'"). The split
|
|
403
|
+
* is deliberate — see {@link parseOrderBySpecStrict} — because a value this
|
|
404
|
+
* function is handed was produced by {@link serializeOrderBy} a moment earlier,
|
|
405
|
+
* and one that reaches the server came from a stranger.
|
|
406
|
+
*
|
|
407
|
+
* A blank field is `undefined` rather than `[" ", "asc"]`: whitespace is not a
|
|
408
|
+
* field name, and the tuple it used to produce could not be re-encoded — the
|
|
409
|
+
* only value in this codec that survived a decode and failed the next encode.
|
|
410
|
+
*
|
|
411
|
+
* Reads the single-key shorthand only. For a value that may carry several keys,
|
|
412
|
+
* use {@link deserializeOrderByList} — handed a JSON array this returns the
|
|
413
|
+
* whole array as one nonsensical field name.
|
|
414
|
+
*
|
|
415
|
+
* @param raw - The wire-format string from an HTTP query parameter.
|
|
416
|
+
* @returns The canonical tuple, or `undefined` if the input names no field.
|
|
417
|
+
*/
|
|
418
|
+
function deserializeOrderBy(raw) {
|
|
419
|
+
if (!raw) return void 0;
|
|
420
|
+
const idx = raw.indexOf(":");
|
|
421
|
+
if (idx === -1) return raw.trim() === "" ? void 0 : [raw, "asc"];
|
|
422
|
+
const field = raw.slice(0, idx);
|
|
423
|
+
if (field.trim() === "") return void 0;
|
|
424
|
+
const rest = raw.slice(idx + 1);
|
|
425
|
+
const nullsIdx = rest.indexOf(":");
|
|
426
|
+
const dir = nullsIdx === -1 ? rest : rest.slice(0, nullsIdx);
|
|
427
|
+
const nulls = nullsIdx === -1 ? void 0 : rest.slice(nullsIdx + 1);
|
|
428
|
+
const direction = dir === "desc" ? "desc" : "asc";
|
|
429
|
+
return nulls === "first" || nulls === "last" ? [
|
|
430
|
+
field,
|
|
431
|
+
direction,
|
|
432
|
+
nulls
|
|
433
|
+
] : [field, direction];
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Deserialize either wire spelling — the single-key shorthand or the JSON
|
|
437
|
+
* array — into the list form.
|
|
438
|
+
*
|
|
439
|
+
* Lenient in the same way {@link deserializeOrderBy} is: this is the client end
|
|
440
|
+
* of the codec, where the value was produced by {@link serializeOrderBy} a
|
|
441
|
+
* moment earlier. The *server* end parses the same shapes strictly, in
|
|
442
|
+
* `parseOrderByParam`, because there the value came from a stranger and a
|
|
443
|
+
* direction it cannot read has to be refused rather than quietly turned into
|
|
444
|
+
* `"asc"`.
|
|
445
|
+
*/
|
|
446
|
+
function deserializeOrderByList(raw) {
|
|
447
|
+
if (!raw) return void 0;
|
|
448
|
+
const trimmed = raw.trim();
|
|
449
|
+
if (trimmed.startsWith("[")) try {
|
|
450
|
+
const parsed = JSON.parse(trimmed);
|
|
451
|
+
if (Array.isArray(parsed)) {
|
|
452
|
+
const list = parsed.map((entry) => {
|
|
453
|
+
if (typeof entry === "string") return deserializeOrderBy(entry);
|
|
454
|
+
if (entry && typeof entry === "object" && typeof entry.field === "string") {
|
|
455
|
+
const direction = entry.direction === "desc" ? "desc" : "asc";
|
|
456
|
+
return entry.nulls === "first" || entry.nulls === "last" ? [
|
|
457
|
+
entry.field,
|
|
458
|
+
direction,
|
|
459
|
+
entry.nulls
|
|
460
|
+
] : [entry.field, direction];
|
|
461
|
+
}
|
|
462
|
+
}).filter((entry) => entry !== void 0);
|
|
463
|
+
return list.length > 0 ? list : void 0;
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
const single = deserializeOrderBy(trimmed);
|
|
467
|
+
return single ? [single] : void 0;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region ../common/src/data/include-spec.ts
|
|
471
|
+
/** An `include` that cannot be read, as opposed to one naming a relation that does not exist. */
|
|
472
|
+
var IncludeSpecError = class IncludeSpecError extends Error {
|
|
473
|
+
code;
|
|
474
|
+
constructor(detail, code = "INVALID_INCLUDE") {
|
|
475
|
+
super(`Invalid \`include\`: ${detail}`);
|
|
476
|
+
this.name = "IncludeSpecError";
|
|
477
|
+
this.code = code;
|
|
478
|
+
Object.setPrototypeOf(this, IncludeSpecError.prototype);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
var emptyNode = () => ({ children: {} });
|
|
482
|
+
function ensureNode(tree, key) {
|
|
483
|
+
return tree[key] ??= emptyNode();
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Merge one dotted path (`"comments.author"`) into a tree.
|
|
487
|
+
*
|
|
488
|
+
* Merging rather than assigning is what makes `include=comments,comments.author`
|
|
489
|
+
* mean the same thing as `include=comments.author`: the second path deepens the
|
|
490
|
+
* node the first created instead of replacing it and losing its options.
|
|
491
|
+
*/
|
|
492
|
+
function addPath(tree, path) {
|
|
493
|
+
const segments = path.split(".").map((s) => s.trim()).filter(Boolean);
|
|
494
|
+
if (segments.length === 0) return;
|
|
495
|
+
if (segments.length > 3) throw new IncludeSpecError(`"${path}" nests ${segments.length} relations deep; the limit is 3. Each hop is another query, and an unbounded one walks a self-referencing relation forever.`, "INCLUDE_TOO_DEEP");
|
|
496
|
+
let level = tree;
|
|
497
|
+
for (const segment of segments) level = ensureNode(level, segment).children;
|
|
498
|
+
}
|
|
499
|
+
function normalizeOptions(key, options, depth) {
|
|
500
|
+
if (depth > 3) throw new IncludeSpecError(`"${key}" nests more than 3 relations deep.`, "INCLUDE_TOO_DEEP");
|
|
501
|
+
if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1)) throw new IncludeSpecError(`"${key}" has limit ${JSON.stringify(options.limit)} — expected a whole number of 1 or more.`);
|
|
502
|
+
if (options.count !== void 0 && typeof options.count !== "boolean") throw new IncludeSpecError(`"${key}" has count ${JSON.stringify(options.count)} — expected \`true\` or \`false\`.`);
|
|
503
|
+
const node = { children: {} };
|
|
504
|
+
if (options.limit !== void 0) node.limit = options.limit;
|
|
505
|
+
if (options.count) node.count = true;
|
|
506
|
+
if (options.where) node.where = options.where;
|
|
507
|
+
if (options.logical) node.logical = options.logical;
|
|
508
|
+
if (options.fields && options.fields.length > 0) node.fields = [...options.fields];
|
|
509
|
+
const orderBy = typeof options.orderBy === "string" ? deserializeOrderByList(options.orderBy) : normalizeOrderBy(options.orderBy);
|
|
510
|
+
if (orderBy) node.orderBy = orderBy;
|
|
511
|
+
if (options.include) {
|
|
512
|
+
const nested = normalizeIncludeAt(options.include, depth + 1);
|
|
513
|
+
if (nested.wildcard) throw new IncludeSpecError(`"${key}" asks for \`*\` inside a nested include. Name the relations you need.`);
|
|
514
|
+
node.children = nested.tree;
|
|
515
|
+
}
|
|
516
|
+
return node;
|
|
517
|
+
}
|
|
518
|
+
function normalizeIncludeAt(spec, depth) {
|
|
519
|
+
if (Array.isArray(spec)) {
|
|
520
|
+
const tree = {};
|
|
521
|
+
let wildcard = false;
|
|
522
|
+
for (const raw of spec) {
|
|
523
|
+
if (typeof raw !== "string") throw new IncludeSpecError(`${typeof raw} is not a relation name`);
|
|
524
|
+
const name = raw.trim();
|
|
525
|
+
if (!name) continue;
|
|
526
|
+
if (name === "*") {
|
|
527
|
+
wildcard = true;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
addPath(tree, name);
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
wildcard,
|
|
534
|
+
tree
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (typeof spec !== "object" || spec === null) throw new IncludeSpecError(`${typeof spec} is not a list of relations or an include tree`);
|
|
538
|
+
const tree = {};
|
|
539
|
+
let wildcard = false;
|
|
540
|
+
for (const [key, value] of Object.entries(spec)) {
|
|
541
|
+
if (key === "*") {
|
|
542
|
+
if (value) wildcard = true;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (value === true) {
|
|
546
|
+
ensureNode(tree, key);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (value === false || value === void 0 || value === null) continue;
|
|
550
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new IncludeSpecError(`"${key}" must be \`true\` or an options object`);
|
|
551
|
+
tree[key] = normalizeOptions(key, value, depth);
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
wildcard,
|
|
555
|
+
tree
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Collapse any {@link IncludeSpec} spelling into one tree.
|
|
560
|
+
*
|
|
561
|
+
* `["author", "comments.author"]` and
|
|
562
|
+
* `{ author: true, comments: { include: { author: true } } }` normalize to the
|
|
563
|
+
* same value — which is the whole point: the REST parameter can only carry the
|
|
564
|
+
* flat spelling, the SDK prefers the tree, and the driver should never learn
|
|
565
|
+
* about either.
|
|
566
|
+
*
|
|
567
|
+
* @throws {IncludeSpecError} for a shape that is not an include at all, or one
|
|
568
|
+
* that nests past {@link MAX_INCLUDE_DEPTH}.
|
|
569
|
+
*/
|
|
570
|
+
function normalizeInclude(spec) {
|
|
571
|
+
if (spec === void 0 || spec === null) return void 0;
|
|
572
|
+
const normalized = normalizeIncludeAt(spec, 1);
|
|
573
|
+
if (!normalized.wildcard && Object.keys(normalized.tree).length === 0) return void 0;
|
|
574
|
+
return normalized;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Every relation name a tree names, as dotted paths — `["comments",
|
|
578
|
+
* "comments.author"]`.
|
|
579
|
+
*
|
|
580
|
+
* Used to report which names an `include` asked for when one of them is not a
|
|
581
|
+
* relation, and to serialize a tree that carries no per-relation options back
|
|
582
|
+
* to the flat wire spelling.
|
|
583
|
+
*/
|
|
584
|
+
function includePaths(tree, prefix = "") {
|
|
585
|
+
const out = [];
|
|
586
|
+
for (const [key, node] of Object.entries(tree)) {
|
|
587
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
588
|
+
out.push(path);
|
|
589
|
+
out.push(...includePaths(node.children, path));
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
/** Whether any node in the tree carries per-relation options. */
|
|
594
|
+
function hasOptions(tree) {
|
|
595
|
+
return Object.values(tree).some((node) => node.limit !== void 0 || node.where !== void 0 || node.logical !== void 0 || node.orderBy !== void 0 || node.fields !== void 0 || node.count !== void 0 || hasOptions(node.children));
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Serialize an {@link IncludeSpec} for the REST `?include=` parameter.
|
|
599
|
+
*
|
|
600
|
+
* Two spellings, and which one is used is decided by the request rather than
|
|
601
|
+
* chosen:
|
|
602
|
+
*
|
|
603
|
+
* - **Comma-separated dotted paths** — `include=author,comments.author`. What a
|
|
604
|
+
* plain include is, what a human types, and what every existing client sends.
|
|
605
|
+
* - **JSON**, when any relation carries options — `include={"comments":{"limit":5,
|
|
606
|
+
* "include":{"author":true}}}`. The flat spelling has nowhere to put a
|
|
607
|
+
* `limit`, and inventing a punctuation for it (`comments(limit:5)`) would be a
|
|
608
|
+
* third grammar to learn beside the two this API already has.
|
|
609
|
+
*
|
|
610
|
+
* The server accepts both on every list and get route, and tells them apart the
|
|
611
|
+
* same way this does: a value starting with `{` is JSON.
|
|
612
|
+
*/
|
|
613
|
+
function serializeInclude(spec) {
|
|
614
|
+
const normalized = normalizeInclude(spec);
|
|
615
|
+
if (!normalized) return void 0;
|
|
616
|
+
if (normalized.wildcard && Object.keys(normalized.tree).length === 0) return "*";
|
|
617
|
+
if (!hasOptions(normalized.tree)) {
|
|
618
|
+
const paths = includePaths(normalized.tree);
|
|
619
|
+
const leaves = paths.filter((path) => !paths.some((other) => other.startsWith(`${path}.`)));
|
|
620
|
+
const all = normalized.wildcard ? ["*", ...leaves] : leaves;
|
|
621
|
+
return all.length > 0 ? all.join(",") : void 0;
|
|
622
|
+
}
|
|
623
|
+
return JSON.stringify(toWireTree(normalized));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* A normalized tree, back in the {@link IncludeSpec} spelling a caller writes.
|
|
627
|
+
*
|
|
628
|
+
* The round trip is what lets a builder accumulate `include` calls: normalize
|
|
629
|
+
* each, merge, and hand the result back as a spec the next layer can normalize
|
|
630
|
+
* again. Idempotent, so doing it twice changes nothing.
|
|
631
|
+
*/
|
|
632
|
+
function denormalizeInclude(normalized) {
|
|
633
|
+
return toWireTree(normalized);
|
|
634
|
+
}
|
|
635
|
+
function mergeTrees(into, from) {
|
|
636
|
+
for (const [key, node] of Object.entries(from)) {
|
|
637
|
+
const existing = into[key];
|
|
638
|
+
if (!existing) {
|
|
639
|
+
into[key] = node;
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (node.limit !== void 0) existing.limit = node.limit;
|
|
643
|
+
if (node.where !== void 0) existing.where = node.where;
|
|
644
|
+
if (node.logical !== void 0) existing.logical = node.logical;
|
|
645
|
+
if (node.orderBy !== void 0) existing.orderBy = node.orderBy;
|
|
646
|
+
if (node.fields !== void 0) existing.fields = node.fields;
|
|
647
|
+
if (node.count !== void 0) existing.count = node.count;
|
|
648
|
+
existing.children = mergeTrees(existing.children, node.children);
|
|
649
|
+
}
|
|
650
|
+
return into;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Combine several `include` requests into one.
|
|
654
|
+
*
|
|
655
|
+
* Repeated `.include(...)` calls on a query builder are additive: each names
|
|
656
|
+
* more of the graph to load, and a later one must not discard what an earlier
|
|
657
|
+
* one asked for. Assigning instead of merging is why `.include("author")
|
|
658
|
+
* .include("tags")` used to load only tags.
|
|
659
|
+
*/
|
|
660
|
+
function mergeIncludeSpecs(existing, additions) {
|
|
661
|
+
const merged = {
|
|
662
|
+
wildcard: false,
|
|
663
|
+
tree: {}
|
|
664
|
+
};
|
|
665
|
+
const absorb = (spec) => {
|
|
666
|
+
const normalized = normalizeInclude(spec);
|
|
667
|
+
if (!normalized) return;
|
|
668
|
+
merged.wildcard ||= normalized.wildcard;
|
|
669
|
+
mergeTrees(merged.tree, normalized.tree);
|
|
670
|
+
};
|
|
671
|
+
absorb(existing);
|
|
672
|
+
const names = additions.filter((a) => typeof a === "string");
|
|
673
|
+
if (names.length > 0) absorb(names);
|
|
674
|
+
for (const addition of additions) if (typeof addition !== "string") absorb(addition);
|
|
675
|
+
if (!merged.wildcard && Object.keys(merged.tree).length === 0) return void 0;
|
|
676
|
+
return denormalizeInclude(merged);
|
|
677
|
+
}
|
|
678
|
+
function toWireTree(normalized) {
|
|
679
|
+
const emit = (tree) => {
|
|
680
|
+
const out = {};
|
|
681
|
+
for (const [key, node] of Object.entries(tree)) {
|
|
682
|
+
const options = {};
|
|
683
|
+
if (node.limit !== void 0) options.limit = node.limit;
|
|
684
|
+
if (node.where) options.where = node.where;
|
|
685
|
+
if (node.logical) options.logical = node.logical;
|
|
686
|
+
if (node.orderBy) options.orderBy = node.orderBy;
|
|
687
|
+
if (node.fields) options.fields = node.fields;
|
|
688
|
+
if (node.count) options.count = true;
|
|
689
|
+
const children = emit(node.children);
|
|
690
|
+
if (Object.keys(children).length > 0) options.include = children;
|
|
691
|
+
out[key] = Object.keys(options).length > 0 ? options : true;
|
|
692
|
+
}
|
|
693
|
+
return out;
|
|
694
|
+
};
|
|
695
|
+
const tree = emit(normalized.tree);
|
|
696
|
+
if (normalized.wildcard) tree["*"] = true;
|
|
697
|
+
return tree;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Read the REST `?include=` parameter, in either spelling.
|
|
701
|
+
*
|
|
702
|
+
* @throws {IncludeSpecError} for malformed JSON or a tree that nests too deep.
|
|
703
|
+
*/
|
|
704
|
+
function deserializeInclude(raw) {
|
|
705
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
706
|
+
const text = raw.trim();
|
|
707
|
+
if (!text) return void 0;
|
|
708
|
+
if (text.startsWith("{")) {
|
|
709
|
+
let parsed;
|
|
710
|
+
try {
|
|
711
|
+
parsed = JSON.parse(text);
|
|
712
|
+
} catch {
|
|
713
|
+
throw new IncludeSpecError("the parametrised form must be a JSON object, e.g. {\"comments\":{\"limit\":5,\"include\":{\"author\":true}}}");
|
|
714
|
+
}
|
|
715
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new IncludeSpecError("the parametrised form must be a JSON object");
|
|
716
|
+
return parsed;
|
|
717
|
+
}
|
|
718
|
+
return text.split(",").map((s) => s.trim()).filter(Boolean);
|
|
719
|
+
}
|
|
720
|
+
/** Rows `findAll()` will materialise before it refuses to continue. */
|
|
721
|
+
var DEFAULT_FIND_ALL_MAX_ROWS = 1e4;
|
|
722
|
+
/**
|
|
723
|
+
* Requests one walk may make before it gives up on the server ever saying
|
|
724
|
+
* `hasMore: false`. At the default page size that is two million rows — far
|
|
725
|
+
* past any legitimate walk, and short of running forever.
|
|
726
|
+
*/
|
|
727
|
+
var DEFAULT_MAX_PAGES = 1e4;
|
|
728
|
+
/**
|
|
729
|
+
* Thrown when a walk stops for a reason the caller needs to know about.
|
|
730
|
+
*
|
|
731
|
+
* Every one of these is a case where the alternative would be silent: a
|
|
732
|
+
* truncated array that looks complete, or a loop that never returns. Check
|
|
733
|
+
* {@link code} to tell them apart.
|
|
734
|
+
*/
|
|
735
|
+
var RebasePaginationError = class RebasePaginationError extends Error {
|
|
736
|
+
code;
|
|
737
|
+
constructor(code, message) {
|
|
738
|
+
super(message);
|
|
739
|
+
this.name = "RebasePaginationError";
|
|
740
|
+
this.code = code;
|
|
741
|
+
Object.setPrototypeOf(this, RebasePaginationError.prototype);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
/**
|
|
745
|
+
* What a read naming both `page` and `offset` is refused with — by the server
|
|
746
|
+
* (400 `PAGE_WITH_OFFSET`) and, before any request, by every client.
|
|
747
|
+
*/
|
|
748
|
+
var PAGE_WITH_OFFSET_MESSAGE = "`page` and `offset` describe the same window; send one.";
|
|
749
|
+
/**
|
|
750
|
+
* Resolve `limit`/`offset`/`page` into the window a read will actually use.
|
|
751
|
+
*
|
|
752
|
+
* Lives here, next to the walk, for the reason at the top of this file: every
|
|
753
|
+
* transport has to mean the same thing by "page two". Four of them did not —
|
|
754
|
+
* the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
|
|
755
|
+
* evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
|
|
756
|
+
* the published type documented a fourth number. Pages that overlap or skip
|
|
757
|
+
* rows are the mildest of those outcomes.
|
|
758
|
+
*
|
|
759
|
+
* `page` and `offset` together are refused with `PAGE_WITH_OFFSET`, as the
|
|
760
|
+
* server refuses them: they name one window twice, and `page` used to win
|
|
761
|
+
* silently over an `offset` the caller then never saw applied. `driverOffset`
|
|
762
|
+
* is the value to hand a driver: it stays `undefined` when the caller named no
|
|
763
|
+
* offset, because keyset pagination seeks with a `where` clause and must not
|
|
764
|
+
* look like it is paging by offset.
|
|
765
|
+
*/
|
|
766
|
+
function resolveFindWindow(params) {
|
|
767
|
+
if (params?.page != null && params.offset != null) throw new RebaseClientError(PAGE_WITH_OFFSET_MESSAGE, { code: "PAGE_WITH_OFFSET" });
|
|
768
|
+
const limit = params?.limit ?? 50;
|
|
769
|
+
const offset = params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0;
|
|
770
|
+
return {
|
|
771
|
+
limit,
|
|
772
|
+
offset,
|
|
773
|
+
driverOffset: params?.page != null ? offset : params?.offset
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function normalizePageSize(raw) {
|
|
777
|
+
if (raw === void 0 || !Number.isFinite(raw)) return 200;
|
|
778
|
+
return Math.max(1, Math.floor(raw));
|
|
779
|
+
}
|
|
780
|
+
function normalizeMaxPages(raw) {
|
|
781
|
+
if (raw === void 0) return DEFAULT_MAX_PAGES;
|
|
782
|
+
if (raw === Number.POSITIVE_INFINITY) return raw;
|
|
783
|
+
if (!Number.isFinite(raw)) return DEFAULT_MAX_PAGES;
|
|
784
|
+
return Math.max(1, Math.floor(raw));
|
|
785
|
+
}
|
|
786
|
+
function normalizeMaxRows(raw) {
|
|
787
|
+
if (raw === void 0) return DEFAULT_FIND_ALL_MAX_ROWS;
|
|
788
|
+
if (raw === Number.POSITIVE_INFINITY) return raw;
|
|
789
|
+
if (!Number.isFinite(raw)) return DEFAULT_FIND_ALL_MAX_ROWS;
|
|
790
|
+
return Math.max(0, Math.floor(raw));
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Walk every row a query matches, yielding one row at a time and fetching the
|
|
794
|
+
* next page only when the consumer asks for it.
|
|
795
|
+
*
|
|
796
|
+
* See {@link SDKCollectionClient.iterate} for the caller-facing contract,
|
|
797
|
+
* including the offset-drift caveat and the `cursor` alternative.
|
|
798
|
+
*
|
|
799
|
+
* @param find the transport's single-page read
|
|
800
|
+
* @param params `find()` parameters minus the window, plus the walk options
|
|
801
|
+
* @param label the collection name, so an error says which walk failed
|
|
802
|
+
*/
|
|
803
|
+
async function* paginateFind(find, params, label = "collection") {
|
|
804
|
+
const { pageSize, cursor, maxPages, after: startAfter, offset: startOffset, page: startPage, ...rest } = params ?? {};
|
|
805
|
+
const findParams = {
|
|
806
|
+
...rest,
|
|
807
|
+
count: "none"
|
|
808
|
+
};
|
|
809
|
+
const size = normalizePageSize(pageSize);
|
|
810
|
+
const pageCap = normalizeMaxPages(maxPages);
|
|
811
|
+
const seekRequested = cursor !== void 0 && cursor !== null || !!startAfter;
|
|
812
|
+
if (cursor !== void 0 && cursor !== null) {
|
|
813
|
+
const field = typeof cursor === "string" ? cursor : cursor.field;
|
|
814
|
+
const requested = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
|
|
815
|
+
if (!normalizeOrderBy(findParams.orderBy)) findParams.orderBy = [field, requested ?? "asc"];
|
|
816
|
+
}
|
|
817
|
+
let offset = resolveFindWindow({
|
|
818
|
+
limit: size,
|
|
819
|
+
offset: startOffset,
|
|
820
|
+
page: startPage
|
|
821
|
+
}).offset;
|
|
822
|
+
let pages = 0;
|
|
823
|
+
let after = startAfter || void 0;
|
|
824
|
+
let seekingByServerCursor = false;
|
|
825
|
+
for (;;) {
|
|
826
|
+
if (pages >= pageCap) throw new RebasePaginationError("max-pages", `Iterating "${label}" made ${pages} requests without the server reporting the end of the collection. Stopping rather than looping forever — raise \`maxPages\` if the walk is genuinely this long, or check that the backend sets \`meta.hasMore\`.`);
|
|
827
|
+
const pageParams = {
|
|
828
|
+
...findParams,
|
|
829
|
+
limit: size
|
|
830
|
+
};
|
|
831
|
+
if (seekRequested) {
|
|
832
|
+
if (after) pageParams.after = after;
|
|
833
|
+
} else if (seekingByServerCursor && after) pageParams.after = after;
|
|
834
|
+
else pageParams.offset = offset;
|
|
835
|
+
const page = await find(pageParams);
|
|
836
|
+
pages += 1;
|
|
837
|
+
const rows = page?.data ?? [];
|
|
838
|
+
if (rows.length === 0) return;
|
|
839
|
+
for (const row of rows) yield row;
|
|
840
|
+
if (page?.meta?.hasMore !== true) return;
|
|
841
|
+
if (seekRequested) {
|
|
842
|
+
const next = page.meta.nextCursor;
|
|
843
|
+
if (!next) throw new RebasePaginationError("cursor-missing", `Cannot seek past the last row of "${label}": the server reported another page but issued no cursor for it. An ordering with no stored value to compare against — relevance (\`_score\`) — cannot key a cursor. Drop \`cursor\` to page by offset.`);
|
|
844
|
+
if (next === after) throw new RebasePaginationError("cursor-stalled", `Iterating "${label}" is stuck: two pages in a row ended on the same cursor, so the walk cannot advance. Continuing would loop forever. Page by offset instead, or report this — a cursor that does not move is a server-side bug.`);
|
|
845
|
+
after = next;
|
|
846
|
+
} else {
|
|
847
|
+
offset += rows.length;
|
|
848
|
+
const next = page.meta.nextCursor;
|
|
849
|
+
seekingByServerCursor = !!next && next !== after;
|
|
850
|
+
after = seekingByServerCursor ? next : void 0;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* {@link paginateFind}, collected into an array under a ceiling.
|
|
856
|
+
*
|
|
857
|
+
* See {@link SDKCollectionClient.findAll}.
|
|
858
|
+
*/
|
|
859
|
+
async function collectAllPages(find, params, label = "collection") {
|
|
860
|
+
const { maxRows, ...rest } = params ?? {};
|
|
861
|
+
const cap = normalizeMaxRows(maxRows);
|
|
862
|
+
const out = [];
|
|
863
|
+
for await (const row of paginateFind(find, rest, label)) {
|
|
864
|
+
out.push(row);
|
|
865
|
+
if (out.length > cap) throw new RebasePaginationError("max-rows", `findAll("${label}") matched more than ${cap} rows. Returning the first ${cap} would look like the whole answer and quietly not be one, so this throws instead. Raise \`maxRows\` if you meant to load them all, or stream with \`iterate()\`.`);
|
|
866
|
+
}
|
|
867
|
+
return out;
|
|
868
|
+
}
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region ../common/src/data/filter-dialect.ts
|
|
871
|
+
/**
|
|
872
|
+
* REST wire-format adapter for the unified filter system.
|
|
873
|
+
*
|
|
874
|
+
* This module is the ONLY code in the entire codebase that knows about
|
|
875
|
+
* PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
|
|
876
|
+
* Everything else speaks `FilterValues` exclusively.
|
|
877
|
+
*
|
|
878
|
+
* Wire-format values are always strings — the wire format carries no type
|
|
879
|
+
* metadata, so type coercion is the responsibility of the server-side data
|
|
880
|
+
* driver which has access to the collection schema.
|
|
881
|
+
*
|
|
882
|
+
* Structural characters inside a value are backslash-escaped: `,` → `\,`,
|
|
883
|
+
* `(` → `\(`, `)` → `\)`, and a literal backslash as `\\`. Decoding is
|
|
884
|
+
* deliberately conservative — only those four sequences are decoded, so a
|
|
885
|
+
* backslash that arrives unescaped from an older client survives intact.
|
|
886
|
+
*
|
|
887
|
+
* @module
|
|
888
|
+
*/
|
|
889
|
+
/**
|
|
890
|
+
* Serialize a JS value to its querystring representation.
|
|
891
|
+
* `null` is serialized as the literal string `"null"`.
|
|
892
|
+
* Relation values (`EntityRelation` instances or `{ __type: "relation", id, path }`
|
|
893
|
+
* objects) are serialized as their raw id — the wire format only carries the
|
|
894
|
+
* value to compare against the FK column.
|
|
895
|
+
*
|
|
896
|
+
* A `Date` is its ISO 8601 instant — what JSON makes of it on the socket and
|
|
897
|
+
* in-process, so one filter names one instant through every door. `String()`
|
|
898
|
+
* gave the viewer's local zone, no milliseconds and a parenthesised zone name
|
|
899
|
+
* Postgres cannot parse. An invalid date keeps its "Invalid Date" spelling for
|
|
900
|
+
* the server to refuse, rather than throwing a `RangeError` from here.
|
|
901
|
+
*/
|
|
902
|
+
function stringifyValue(value) {
|
|
903
|
+
if (value === null) return "null";
|
|
904
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
905
|
+
const relation = normalizeToEntityRelation(value);
|
|
906
|
+
if (relation) return String(relation.id);
|
|
907
|
+
return String(value);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Characters that carry structure in the wire format and must therefore be
|
|
911
|
+
* escaped inside a value: the separator, the group delimiters, and the escape
|
|
912
|
+
* character itself.
|
|
913
|
+
*
|
|
914
|
+
* Parentheses are here because `and(...)`/`or(...)` groups are parsed by
|
|
915
|
+
* tracking paren depth. A value containing one is not merely ambiguous, it
|
|
916
|
+
* moves where the parser thinks the group ends.
|
|
917
|
+
*/
|
|
918
|
+
var WIRE_SPECIALS = /[\\,()]/g;
|
|
919
|
+
/**
|
|
920
|
+
* Escape a value for the wire format: `\` → `\\`, `,` → `\,`, `(` → `\(`,
|
|
921
|
+
* `)` → `\)`.
|
|
922
|
+
*/
|
|
923
|
+
/**
|
|
924
|
+
* The wire spelling of an empty list.
|
|
925
|
+
*
|
|
926
|
+
* A lone backslash: unproducible by {@link escapeWireValue}, which doubles
|
|
927
|
+
* every backslash it emits, so it cannot collide with any real item.
|
|
928
|
+
*/
|
|
929
|
+
var EMPTY_LIST_TOKEN = "\\";
|
|
930
|
+
function escapeWireValue(value) {
|
|
931
|
+
return value.replace(WIRE_SPECIALS, (ch) => `\\${ch}`);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Unescape a wire-format value.
|
|
935
|
+
*
|
|
936
|
+
* **Conservative**, and deliberately so: only the four sequences
|
|
937
|
+
* {@link escapeWireValue} actually produces are decoded. A backslash followed
|
|
938
|
+
* by anything else is left exactly as it is.
|
|
939
|
+
*
|
|
940
|
+
* This used to consume the backslash before *any* character, which is
|
|
941
|
+
* indistinguishable for anything this codec emitted — it only ever emits those
|
|
942
|
+
* four — but not for input arriving from elsewhere. A client on an older
|
|
943
|
+
* release sends a Windows path or a LIKE pattern with a literal `C:\x`
|
|
944
|
+
* unescaped, and greedy unescaping silently turned it into `C:x`, changing
|
|
945
|
+
* which rows matched. Decoding only what the encoder can produce makes the two
|
|
946
|
+
* directions agree across versions.
|
|
947
|
+
*/
|
|
948
|
+
function unescapeWireValue(value) {
|
|
949
|
+
let result = "";
|
|
950
|
+
for (let i = 0; i < value.length; i++) {
|
|
951
|
+
const next = value[i + 1];
|
|
952
|
+
if (value[i] === "\\" && (next === "\\" || next === "," || next === "(" || next === ")")) {
|
|
953
|
+
result += next;
|
|
954
|
+
i++;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
result += value[i];
|
|
958
|
+
}
|
|
959
|
+
return result;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Whether a scalar, sent raw as a query parameter's operand, would be read
|
|
963
|
+
* back as something other than itself.
|
|
964
|
+
*
|
|
965
|
+
* A query parameter owns its whole value, so its scalar goes out unescaped —
|
|
966
|
+
* `eq.a,b`, `eq.C:\x` — and the decoder returns it verbatim. Two shapes break
|
|
967
|
+
* that. A parenthesised operand is a list for every operator (so that
|
|
968
|
+
* `["==", ["a","b"]]` on an array column stays a list), so raw, the scalar
|
|
969
|
+
* `"(deleted)"` would arrive as `["deleted"]`: an `==` matching nothing, a
|
|
970
|
+
* `!=` returning the very rows it excludes. Such a scalar is sent escaped,
|
|
971
|
+
* `eq.\(deleted\)`. And that spelling has to be told apart from a raw value
|
|
972
|
+
* that looks the same, so a scalar of *that* shape is escaped in turn.
|
|
973
|
+
*
|
|
974
|
+
* Everything else stays raw, so a value an older client sends decodes as it
|
|
975
|
+
* always has — unless it is exactly the escaped spelling of such a scalar,
|
|
976
|
+
* which decodes to the scalar it spells.
|
|
977
|
+
*/
|
|
978
|
+
function scalarNeedsEscape(value) {
|
|
979
|
+
if (value.startsWith("(") && value.endsWith(")")) return true;
|
|
980
|
+
return decodeEscapedScalar(value) !== void 0;
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* The scalar an operand is the escaped spelling of, when it is one.
|
|
984
|
+
*
|
|
985
|
+
* Exact on purpose: only an operand that {@link escapeWireValue} produces from
|
|
986
|
+
* a scalar that {@link scalarNeedsEscape} is decoded. Any other operand —
|
|
987
|
+
* including a raw backslash an older client sent, like `\\server\share` — is
|
|
988
|
+
* left verbatim. Terminates because an operand that matches contains an
|
|
989
|
+
* escape pair, so the value is strictly shorter than it.
|
|
990
|
+
*/
|
|
991
|
+
function decodeEscapedScalar(operand) {
|
|
992
|
+
if (!operand.startsWith("\\")) return void 0;
|
|
993
|
+
const value = unescapeWireValue(operand);
|
|
994
|
+
return escapeWireValue(value) === operand && scalarNeedsEscape(value) ? value : void 0;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Split a parenthesized list string on unescaped commas.
|
|
998
|
+
* Input is the content between `(` and `)`.
|
|
999
|
+
*
|
|
1000
|
+
* @example
|
|
1001
|
+
* splitListItems("admin,editor") // ["admin", "editor"]
|
|
1002
|
+
* splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
|
|
1003
|
+
*/
|
|
1004
|
+
function splitListItems(inner) {
|
|
1005
|
+
const items = [];
|
|
1006
|
+
let current = "";
|
|
1007
|
+
for (let i = 0; i < inner.length; i++) if (inner[i] === "\\" && i + 1 < inner.length) {
|
|
1008
|
+
current += inner[i] + inner[i + 1];
|
|
1009
|
+
i++;
|
|
1010
|
+
} else if (inner[i] === ",") {
|
|
1011
|
+
items.push(unescapeWireValue(current));
|
|
1012
|
+
current = "";
|
|
1013
|
+
} else current += inner[i];
|
|
1014
|
+
items.push(unescapeWireValue(current));
|
|
1015
|
+
return items;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Split a group body on commas at paren depth 0, honouring escapes.
|
|
1019
|
+
*
|
|
1020
|
+
* The escape-awareness is the point. The splitter used to track only paren
|
|
1021
|
+
* depth, so a comma inside a scalar value ended a condition:
|
|
1022
|
+
* `or(name.eq.Doe, John,age.gte.18)` parsed as *three* conditions, the middle
|
|
1023
|
+
* one a fabricated `" John" == true`. On an `or` that widens the result set,
|
|
1024
|
+
* and nothing anywhere reports an error — the query simply stops meaning what
|
|
1025
|
+
* the caller wrote.
|
|
1026
|
+
*/
|
|
1027
|
+
function splitGroupItems(inner) {
|
|
1028
|
+
const parts = [];
|
|
1029
|
+
let depth = 0;
|
|
1030
|
+
let start = 0;
|
|
1031
|
+
for (let i = 0; i < inner.length; i++) {
|
|
1032
|
+
const ch = inner[i];
|
|
1033
|
+
if (ch === "\\" && i + 1 < inner.length) {
|
|
1034
|
+
i++;
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (ch === "(") depth++;
|
|
1038
|
+
else if (ch === ")") depth--;
|
|
1039
|
+
else if (ch === "," && depth === 0) {
|
|
1040
|
+
parts.push(inner.slice(start, i));
|
|
1041
|
+
start = i + 1;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
parts.push(inner.slice(start));
|
|
1045
|
+
return parts;
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Operator tables as `Map`s, because the key comes off the wire.
|
|
1049
|
+
*
|
|
1050
|
+
* Indexed as plain objects, every `Object.prototype` member answered: a query
|
|
1051
|
+
* string of `?f=valueOf.x` found a truthy "operator" — the inherited function —
|
|
1052
|
+
* and `deserializeTuple` returned it *as the operator*, so a function object
|
|
1053
|
+
* travelled on into the compilers in place of a `WhereFilterOp`. The guard one
|
|
1054
|
+
* line below (`if (!canonicalOp)`) reads as though it rejects anything unknown,
|
|
1055
|
+
* and does not: `Object.prototype` is not unknown to a plain object.
|
|
1056
|
+
*
|
|
1057
|
+
* Same shape as the prototype-key defects swept out of `setIn`, `getIn`,
|
|
1058
|
+
* `mergeDeep`, `unflattenObject` and `FOREIGN_CONVENTION_UIDS`.
|
|
1059
|
+
*/
|
|
1060
|
+
var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
|
|
1061
|
+
var CANONICAL_OP_LOOKUP = new Map(Object.entries(CANONICAL_TO_REST));
|
|
1062
|
+
/** The operator spellings a rejection lists back to the caller. */
|
|
1063
|
+
var VALID_OPERATOR_LIST = ALL_WHERE_FILTER_OPS.join(", ");
|
|
1064
|
+
/**
|
|
1065
|
+
* A filter condition named an operator this dialect does not have.
|
|
1066
|
+
*
|
|
1067
|
+
* ## Why this throws, rather than returning a typed rejection
|
|
1068
|
+
*
|
|
1069
|
+
* `deserializeFilter` is the *shared* codec: the REST ingress
|
|
1070
|
+
* (`packages/server/src/api/rest/query-parser.ts`), the browser SDK and the
|
|
1071
|
+
* admin panel (`buildRebaseData.ts`) all decode through it. Two constraints
|
|
1072
|
+
* follow.
|
|
1073
|
+
*
|
|
1074
|
+
* - It cannot throw the server's `ApiError`. `@rebasepro/common` does not
|
|
1075
|
+
* depend on `@rebasepro/server` (the dependency runs the other way), and a
|
|
1076
|
+
* browser client has no error handler to render an `ApiError` with. So the
|
|
1077
|
+
* rejection is this plain `Error` subclass, whose `message` reads correctly
|
|
1078
|
+
* wherever it surfaces — a rejected promise in an app, a 400 body over HTTP.
|
|
1079
|
+
* - It cannot be a returned rejection *value*. Every caller assigns the result
|
|
1080
|
+
* straight into a query it is about to run; a sentinel that none of them
|
|
1081
|
+
* check would be ignored, which is exactly the silently-wrong-filter failure
|
|
1082
|
+
* this exists to stop. Throwing is also what this file already does for the
|
|
1083
|
+
* sibling cases — `serializeTuple` on an unknown canonical operator,
|
|
1084
|
+
* `deserializeLogicalCondition` past the nesting bound — and the REST parser
|
|
1085
|
+
* already converts the latter into a 400.
|
|
1086
|
+
*
|
|
1087
|
+
* `statusCode`, `code` and `details` are carried as fields because the server's
|
|
1088
|
+
* Hono error handler duck-types those off any thrown error: a decode path that
|
|
1089
|
+
* forgets to convert still answers 400 with the canonical envelope instead of a
|
|
1090
|
+
* 500 that says "An unexpected error occurred". `query-parser.ts` converts
|
|
1091
|
+
* explicitly all the same — that is the path the contract is stated on, and an
|
|
1092
|
+
* incidental 400 is not a contract.
|
|
1093
|
+
*/
|
|
1094
|
+
var UnknownFilterOperatorError = class extends Error {
|
|
1095
|
+
/** The field the condition was written against. */
|
|
1096
|
+
field;
|
|
1097
|
+
/** The operator string as it arrived, verbatim. */
|
|
1098
|
+
operator;
|
|
1099
|
+
/** Every operator this dialect accepts, in canonical spelling. */
|
|
1100
|
+
validOperators = ALL_WHERE_FILTER_OPS;
|
|
1101
|
+
/** See the class docblock: read by the server's error handler. */
|
|
1102
|
+
statusCode = 400;
|
|
1103
|
+
code = "UNKNOWN_FILTER_OPERATOR";
|
|
1104
|
+
details;
|
|
1105
|
+
constructor(field, operator) {
|
|
1106
|
+
super(`Unknown filter operator '${operator}' on field '${field}'. Valid operators: ${VALID_OPERATOR_LIST}`);
|
|
1107
|
+
this.name = "UnknownFilterOperatorError";
|
|
1108
|
+
this.field = field;
|
|
1109
|
+
this.operator = operator;
|
|
1110
|
+
this.details = {
|
|
1111
|
+
field,
|
|
1112
|
+
operator,
|
|
1113
|
+
validOperators: ALL_WHERE_FILTER_OPS
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
/**
|
|
1118
|
+
* Two to three characters of ASCII punctuation and nothing else — the shape
|
|
1119
|
+
* every symbolic operator has (`==`, `>=`, `<>`, `~~`, `!!`, `>>`, `===`), and
|
|
1120
|
+
* one a column value effectively never has.
|
|
1121
|
+
*
|
|
1122
|
+
* Two characters minimum on purpose. A *single* punctuation character is a
|
|
1123
|
+
* perfectly ordinary value — `{ grade: ["-", "+"] }` is a two-item list, not a
|
|
1124
|
+
* condition — and the only single-character operator anyone actually mistypes
|
|
1125
|
+
* is `=`, which is named separately below. `<` and `>` need no special case:
|
|
1126
|
+
* they are real operators and resolve.
|
|
1127
|
+
*/
|
|
1128
|
+
var SYMBOLIC_OPERATOR = /^[^\p{L}\p{N}\s]{2,3}$/u;
|
|
1129
|
+
/** Lowercase, strip everything that is not a letter or digit. */
|
|
1130
|
+
function normalizeOperatorName(op) {
|
|
1131
|
+
return op.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Every real operator name with its case and separators removed, so a
|
|
1135
|
+
* respelling of one — `arrayContains`, `not_in`, `NOT-LIKE`, `isNull` — is
|
|
1136
|
+
* recognised as an attempt at an operator rather than read as a value.
|
|
1137
|
+
*
|
|
1138
|
+
* These are rejected rather than accepted: admitting a second spelling of an
|
|
1139
|
+
* operator would leave two wire spellings of one thing, and the rejection
|
|
1140
|
+
* message names the one that works.
|
|
1141
|
+
*/
|
|
1142
|
+
var RESPELLED_OPERATORS = new Set([...ALL_WHERE_FILTER_OPS, ...Object.keys(REST_TO_CANONICAL)].map(normalizeOperatorName));
|
|
1143
|
+
/**
|
|
1144
|
+
* Operator names *other* query dialects use, which this one does not have.
|
|
1145
|
+
*
|
|
1146
|
+
* This list is curated, and deliberately so. For a word-shaped string there is
|
|
1147
|
+
* no rule that separates "an operator the caller guessed" from "a value that
|
|
1148
|
+
* happens to be a word": `{ tags: ["a", "b"] }` has to keep meaning a two-item
|
|
1149
|
+
* `in` list, so the codec cannot simply refuse every unrecognised word in
|
|
1150
|
+
* position 0. The line is therefore drawn by name, and only around names whose
|
|
1151
|
+
* use as an operator is far more likely than their use as one of two sibling
|
|
1152
|
+
* values. `contains` is the motivating case — the first thing a developer
|
|
1153
|
+
* reaches for, and until now it compiled to `title IN ('contains', 'Hell')`.
|
|
1154
|
+
*
|
|
1155
|
+
* Genuinely ambiguous single words (`any`, `all`, `exists`, `search`, `not`)
|
|
1156
|
+
* are left off: as operators they are rare, and as enum values they are common.
|
|
1157
|
+
* Everywhere else the tie goes to *rejecting*, because a 400 naming the
|
|
1158
|
+
* supported set costs the caller one round trip, and the alternative — which is
|
|
1159
|
+
* what every name on this list used to produce — is a query that runs, returns
|
|
1160
|
+
* rows, and is wrong.
|
|
1161
|
+
*/
|
|
1162
|
+
var NEAR_MISS_OPERATORS = /* @__PURE__ */ new Set([
|
|
1163
|
+
"contains",
|
|
1164
|
+
"notcontains",
|
|
1165
|
+
"doesnotcontain",
|
|
1166
|
+
"doesnotcontains",
|
|
1167
|
+
"includes",
|
|
1168
|
+
"notincludes",
|
|
1169
|
+
"startswith",
|
|
1170
|
+
"notstartswith",
|
|
1171
|
+
"beginswith",
|
|
1172
|
+
"startingwith",
|
|
1173
|
+
"endswith",
|
|
1174
|
+
"notendswith",
|
|
1175
|
+
"matches",
|
|
1176
|
+
"notmatches",
|
|
1177
|
+
"regex",
|
|
1178
|
+
"regexp",
|
|
1179
|
+
"between",
|
|
1180
|
+
"notbetween",
|
|
1181
|
+
"equals",
|
|
1182
|
+
"notequals",
|
|
1183
|
+
"equalto",
|
|
1184
|
+
"isequalto",
|
|
1185
|
+
"isnotequalto",
|
|
1186
|
+
"greaterthan",
|
|
1187
|
+
"greaterthanorequal",
|
|
1188
|
+
"greaterthanorequalto",
|
|
1189
|
+
"lessthan",
|
|
1190
|
+
"lessthanorequal",
|
|
1191
|
+
"lessthanorequalto",
|
|
1192
|
+
"isempty",
|
|
1193
|
+
"isnotempty",
|
|
1194
|
+
"oneof",
|
|
1195
|
+
"noneof",
|
|
1196
|
+
"anyof",
|
|
1197
|
+
"allof",
|
|
1198
|
+
"null",
|
|
1199
|
+
"isnullorempty"
|
|
1200
|
+
]);
|
|
1201
|
+
/**
|
|
1202
|
+
* Was this string *meant* as an operator?
|
|
1203
|
+
*
|
|
1204
|
+
* Only consulted after {@link toCanonicalOp} has already failed to resolve it,
|
|
1205
|
+
* so a `true` here is always a rejection.
|
|
1206
|
+
*/
|
|
1207
|
+
function isOperatorShaped(op) {
|
|
1208
|
+
if (op === "=") return true;
|
|
1209
|
+
if (SYMBOLIC_OPERATOR.test(op)) return true;
|
|
1210
|
+
const normalized = normalizeOperatorName(op);
|
|
1211
|
+
if (!normalized) return false;
|
|
1212
|
+
return RESPELLED_OPERATORS.has(normalized) || NEAR_MISS_OPERATORS.has(normalized);
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Read a `[op, value]` tuple, if that is what this is.
|
|
1216
|
+
*
|
|
1217
|
+
* Three outcomes, and the middle one is the defect this function exists for:
|
|
1218
|
+
*
|
|
1219
|
+
* - the operator resolves (canonical *or* REST spelling) → the canonical tuple;
|
|
1220
|
+
* - the operator does not resolve but was plainly meant as one → throw;
|
|
1221
|
+
* - it does not look like an operator at all → `undefined`, and the caller
|
|
1222
|
+
* falls back to reading the array as a list of values.
|
|
1223
|
+
*
|
|
1224
|
+
* The old test was `toCanonicalOp(raw[0]) === raw[0]`, i.e. canonical spelling
|
|
1225
|
+
* only, with *everything else* — including every REST short-code — dropping
|
|
1226
|
+
* through to `["in", raw]`. So the operator string itself became a value in a
|
|
1227
|
+
* membership test: `["!!", "Hello"]` compiled to `title IN ('!!','Hello')`,
|
|
1228
|
+
* which matches, and the caller got back rows their filter was written to
|
|
1229
|
+
* exclude. `["eq", "active"]` had the same shape of failure.
|
|
1230
|
+
*/
|
|
1231
|
+
function readTuple(field, raw) {
|
|
1232
|
+
if (!Array.isArray(raw) || raw.length !== 2) return void 0;
|
|
1233
|
+
const [op, value] = raw;
|
|
1234
|
+
if (typeof op !== "string") return void 0;
|
|
1235
|
+
const canonical = toCanonicalOp(op);
|
|
1236
|
+
if (canonical) return [canonical, value];
|
|
1237
|
+
if (op.includes(".")) return void 0;
|
|
1238
|
+
if (isOperatorShaped(op)) throw new UnknownFilterOperatorError(field, op);
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The query-parameter names a REST listing reads as something other than a
|
|
1242
|
+
* field filter: pagination and whether to count, sorting, includes,
|
|
1243
|
+
* projection, search, the logical groups, the JSON `where` dialect, an
|
|
1244
|
+
* aggregate's `select`/`groupBy`, and the soft-delete switches.
|
|
1245
|
+
*
|
|
1246
|
+
* One list for both ends. The server skips these names when it collects
|
|
1247
|
+
* `?<field>=<op>.<value>` filters, and the SDK sends a filter on a column with
|
|
1248
|
+
* one of these names inside `?where=` instead — a collection is free to have a
|
|
1249
|
+
* `page`, `select` or `hard` column, and a filter on it sent as its own
|
|
1250
|
+
* parameter was read as the parameter and never applied.
|
|
1251
|
+
*/
|
|
1252
|
+
var RESERVED_QUERY_KEYS = /* @__PURE__ */ new Set([
|
|
1253
|
+
"limit",
|
|
1254
|
+
"offset",
|
|
1255
|
+
"page",
|
|
1256
|
+
"after",
|
|
1257
|
+
"orderBy",
|
|
1258
|
+
"include",
|
|
1259
|
+
"fields",
|
|
1260
|
+
"distinct",
|
|
1261
|
+
"count",
|
|
1262
|
+
"searchString",
|
|
1263
|
+
"searchExplain",
|
|
1264
|
+
"vector_search",
|
|
1265
|
+
"vector",
|
|
1266
|
+
"vector_distance",
|
|
1267
|
+
"vector_threshold",
|
|
1268
|
+
"or",
|
|
1269
|
+
"and",
|
|
1270
|
+
"not",
|
|
1271
|
+
"where",
|
|
1272
|
+
"select",
|
|
1273
|
+
"groupBy",
|
|
1274
|
+
"deleted",
|
|
1275
|
+
"hard"
|
|
1276
|
+
]);
|
|
1277
|
+
/**
|
|
1278
|
+
* Encode the `<op>.<value>` half of a wire condition.
|
|
1279
|
+
*
|
|
1280
|
+
* This is the single leaf encoder. Both wire positions that carry a condition
|
|
1281
|
+
* — a top-level query parameter (`?status=eq.active`) and a leaf inside an
|
|
1282
|
+
* `and(...)`/`or(...)` group (`or(status.eq.active,…)`) — go through it, so a
|
|
1283
|
+
* rule expressed here holds in both. The group serializer used to carry its
|
|
1284
|
+
* own copy, and the copy had drifted on every rule that matters: `null` went
|
|
1285
|
+
* out as the four-character string, the empty list as `()`, and an operator
|
|
1286
|
+
* this dialect does not have was silently rewritten to `eq` — a filter that
|
|
1287
|
+
* ran, returned rows, and answered a different question than the one asked.
|
|
1288
|
+
*
|
|
1289
|
+
* `escapeScalar` is the one thing the two positions legitimately disagree
|
|
1290
|
+
* about. A scalar in a query parameter owns the whole value and needs no
|
|
1291
|
+
* escaping; a scalar inside a group sits between the same commas a list item
|
|
1292
|
+
* does, so a comma in it would end the condition early.
|
|
1293
|
+
*/
|
|
1294
|
+
function serializeOperatorAndValue(op, value, { escapeScalar, where }) {
|
|
1295
|
+
if (typeof op !== "string") throw new TypeError(`${where}: operator must be a string, got ${typeof op}`);
|
|
1296
|
+
const restOp = CANONICAL_OP_LOOKUP.get(op);
|
|
1297
|
+
if (!restOp) throw new TypeError(`${where}: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`);
|
|
1298
|
+
if (value === null && (op === "==" || op === "!=")) return op === "==" ? "isnull.null" : "notnull.null";
|
|
1299
|
+
if (NULL_OPS.has(op)) return `${restOp}.null`;
|
|
1300
|
+
if (Array.isArray(value)) {
|
|
1301
|
+
if (value.length === 0) return `${restOp}.(${EMPTY_LIST_TOKEN})`;
|
|
1302
|
+
return `${restOp}.(${value.map((v) => escapeWireValue(stringifyValue(v))).join(",")})`;
|
|
1303
|
+
}
|
|
1304
|
+
const scalar = stringifyValue(value);
|
|
1305
|
+
return `${restOp}.${escapeScalar || !LIST_OPS.has(op) && scalarNeedsEscape(scalar) ? escapeWireValue(scalar) : scalar}`;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Serialize a single canonical condition tuple to a PostgREST dot-string.
|
|
1309
|
+
*
|
|
1310
|
+
* Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
|
|
1311
|
+
*
|
|
1312
|
+
* @example
|
|
1313
|
+
* serializeTuple(["==", "active"]) // "eq.active"
|
|
1314
|
+
* serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
|
|
1315
|
+
* serializeTuple([">=", 18]) // "gte.18"
|
|
1316
|
+
*/
|
|
1317
|
+
function serializeTuple(tuple) {
|
|
1318
|
+
if (!Array.isArray(tuple) || tuple.length !== 2) throw new TypeError(`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`);
|
|
1319
|
+
const [op, value] = tuple;
|
|
1320
|
+
return serializeOperatorAndValue(op, value, {
|
|
1321
|
+
escapeScalar: false,
|
|
1322
|
+
where: "serializeTuple"
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
|
|
1327
|
+
* querystring record.
|
|
1328
|
+
*
|
|
1329
|
+
* - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
|
|
1330
|
+
* - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
|
|
1331
|
+
* - Single conditions produce a string value.
|
|
1332
|
+
* - Multiple conditions on the same field produce a string array (repeated params).
|
|
1333
|
+
*
|
|
1334
|
+
* @example
|
|
1335
|
+
* serializeFilter({ status: ["==", "active"] })
|
|
1336
|
+
* // → { status: "eq.active" }
|
|
1337
|
+
*
|
|
1338
|
+
* serializeFilter({ age: [[">=", 18], ["<", 65]] })
|
|
1339
|
+
* // → { age: ["gte.18", "lt.65"] }
|
|
1340
|
+
*
|
|
1341
|
+
* // Pre-serialized strings pass through unchanged:
|
|
1342
|
+
* serializeFilter({ status: "eq.published" })
|
|
1343
|
+
* // → { status: "eq.published" }
|
|
1344
|
+
*/
|
|
1345
|
+
function serializeFilter(filter) {
|
|
1346
|
+
const result = {};
|
|
1347
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
1348
|
+
if (condition === void 0) continue;
|
|
1349
|
+
if (typeof condition === "string") {
|
|
1350
|
+
result[field] = condition;
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
|
|
1354
|
+
else result[field] = serializeTuple(condition);
|
|
1355
|
+
}
|
|
1356
|
+
return result;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* The spellings a null-testing operator's operand may take.
|
|
1360
|
+
*
|
|
1361
|
+
* The serializer writes `isnull.null`; a hand-written `isnull.true` means the
|
|
1362
|
+
* same thing and has always been accepted. Anything else after the operator is
|
|
1363
|
+
* not an operand it has — `notnull.reason` is a *value* — see
|
|
1364
|
+
* {@link deserializeSingle}.
|
|
1365
|
+
*/
|
|
1366
|
+
var NULL_OPERANDS = /* @__PURE__ */ new Set([
|
|
1367
|
+
"null",
|
|
1368
|
+
"true",
|
|
1369
|
+
"false",
|
|
1370
|
+
""
|
|
1371
|
+
]);
|
|
1372
|
+
/**
|
|
1373
|
+
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
1374
|
+
*
|
|
1375
|
+
* All values are returned as strings — the wire format carries no type
|
|
1376
|
+
* metadata, so coercion is the data driver's responsibility.
|
|
1377
|
+
*
|
|
1378
|
+
* ## When a leading segment is an operator, and when it is part of the value
|
|
1379
|
+
*
|
|
1380
|
+
* `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean
|
|
1381
|
+
* entirely different things, and the reading here decides which. The rule, in
|
|
1382
|
+
* full:
|
|
1383
|
+
*
|
|
1384
|
+
* > A dot-string is read as `operator.operand` **only** when its first segment
|
|
1385
|
+
* > names a known REST operator **and** what follows is a well-formed operand
|
|
1386
|
+
* > *for that operator's arity*. Otherwise the whole string is the value.
|
|
1387
|
+
*
|
|
1388
|
+
* Arity, per operator family:
|
|
1389
|
+
*
|
|
1390
|
+
* - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised
|
|
1391
|
+
* list and nothing else. `in.(draft,review)` is the operator; `in.progress`
|
|
1392
|
+
* is the *value* `"in.progress"`, because there is no list there and so no
|
|
1393
|
+
* `in` filter that could have been written. That case used to compile to
|
|
1394
|
+
* `status IN ('progress')` — a filter the caller never wrote, quietly
|
|
1395
|
+
* matching the wrong rows and, on a status field, hiding every row they were
|
|
1396
|
+
* looking for.
|
|
1397
|
+
* - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no
|
|
1398
|
+
* operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value
|
|
1399
|
+
* `"notnull.reason"`, not "reason is not null".
|
|
1400
|
+
* - **Everything else** takes one scalar, and any remainder is one — including
|
|
1401
|
+
* the empty string, so `eq.` really is "equals the empty string". A
|
|
1402
|
+
* parenthesised remainder is a list for these too (`eq.(a,b)` is array
|
|
1403
|
+
* equality); a scalar that is itself parenthesised arrives escaped,
|
|
1404
|
+
* `eq.\(none\)`, and is decoded here — see {@link scalarNeedsEscape}.
|
|
1405
|
+
*
|
|
1406
|
+
* ### The one ambiguity that remains, and how to write past it
|
|
1407
|
+
*
|
|
1408
|
+
* A scalar operator's operand is unconstrained, so `?status=like.that` is a
|
|
1409
|
+
* `LIKE 'that'` and no rule at this layer can tell it from the literal value
|
|
1410
|
+
* `"like.that"` — both are well-formed encodings, and picking either by guess
|
|
1411
|
+
* would break the other. Two spellings say "value" unambiguously, and both
|
|
1412
|
+
* round-trip:
|
|
1413
|
+
*
|
|
1414
|
+
* - `?status=eq.like.that` — name the operator. The *first* segment is consumed
|
|
1415
|
+
* as the operator and everything after it is the value, dots and all. This is
|
|
1416
|
+
* what `serializeFilter` emits, which is why the SDK never meets the
|
|
1417
|
+
* ambiguity at all.
|
|
1418
|
+
* - `?where={"status":["==","like.that"]}` — the JSON dialect's tuple form.
|
|
1419
|
+
*
|
|
1420
|
+
* Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never
|
|
1421
|
+
* ambiguous: their first segment names no operator to begin with.
|
|
1422
|
+
*/
|
|
1423
|
+
function deserializeSingle(raw) {
|
|
1424
|
+
const dotIndex = raw.indexOf(".");
|
|
1425
|
+
if (dotIndex === -1) return ["==", raw];
|
|
1426
|
+
const prefix = raw.substring(0, dotIndex);
|
|
1427
|
+
const rest = raw.substring(dotIndex + 1);
|
|
1428
|
+
const canonicalOp = REST_OP_LOOKUP.get(prefix);
|
|
1429
|
+
if (!canonicalOp) return ["==", raw];
|
|
1430
|
+
if (NULL_OPS.has(canonicalOp)) {
|
|
1431
|
+
if (!NULL_OPERANDS.has(rest)) return ["==", raw];
|
|
1432
|
+
return [canonicalOp, null];
|
|
1433
|
+
}
|
|
1434
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
1435
|
+
const inner = rest.slice(1, -1);
|
|
1436
|
+
return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
|
|
1437
|
+
}
|
|
1438
|
+
if (LIST_OPS.has(canonicalOp)) return ["==", raw];
|
|
1439
|
+
return [canonicalOp, decodeEscapedScalar(rest) ?? rest];
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Convert a PostgREST-style querystring record to `FilterValues`.
|
|
1443
|
+
*
|
|
1444
|
+
* - String values are parsed as single conditions.
|
|
1445
|
+
* - String arrays (repeated query params) become multiple conditions on the same field.
|
|
1446
|
+
*
|
|
1447
|
+
* @example
|
|
1448
|
+
* deserializeFilter({ status: "eq.active" })
|
|
1449
|
+
* // → { status: ["==", "active"] }
|
|
1450
|
+
*
|
|
1451
|
+
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
1452
|
+
* // → { age: [[">=", "18"], ["<", "65"]] }
|
|
1453
|
+
*
|
|
1454
|
+
* @throws {UnknownFilterOperatorError} when a condition names an operator this
|
|
1455
|
+
* dialect does not have. See that class for why a rejection here is a throw.
|
|
1456
|
+
*/
|
|
1457
|
+
function deserializeFilter(query) {
|
|
1458
|
+
const result = {};
|
|
1459
|
+
for (const [field, raw] of Object.entries(query)) {
|
|
1460
|
+
if (raw === void 0) continue;
|
|
1461
|
+
const tuple = readTuple(field, raw);
|
|
1462
|
+
if (tuple) {
|
|
1463
|
+
result[field] = tuple;
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (Array.isArray(raw)) {
|
|
1467
|
+
if (raw.length === 0) continue;
|
|
1468
|
+
if (Array.isArray(raw[0])) {
|
|
1469
|
+
const tuples = raw.map((item) => readTuple(field, item));
|
|
1470
|
+
if (tuples.every((t) => t !== void 0)) {
|
|
1471
|
+
result[field] = tuples;
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
1476
|
+
else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
|
|
1477
|
+
else result[field] = ["in", raw];
|
|
1478
|
+
} else if (typeof raw === "string") result[field] = deserializeSingle(raw);
|
|
1479
|
+
else result[field] = ["==", raw];
|
|
1480
|
+
}
|
|
1481
|
+
return result;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
|
|
1485
|
+
*
|
|
1486
|
+
* Leaf encoding is {@link serializeOperatorAndValue}, the same function
|
|
1487
|
+
* `serializeTuple` uses, so `null`, the empty list and an unknown operator
|
|
1488
|
+
* behave identically inside a group and in a query parameter.
|
|
1489
|
+
*
|
|
1490
|
+
* @throws {TypeError} when a leaf names an operator this dialect does not have.
|
|
1491
|
+
* It used to fall back to `eq`, which turned `age >= 18` into `age = 18` with
|
|
1492
|
+
* no diagnostic anywhere.
|
|
1493
|
+
*
|
|
1494
|
+
* @example
|
|
1495
|
+
* serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
|
|
1496
|
+
* // → "status.eq.active"
|
|
1497
|
+
*
|
|
1498
|
+
* serializeLogicalCondition({ column: "deleted_at", operator: "==", value: null })
|
|
1499
|
+
* // → "deleted_at.isnull.null"
|
|
1500
|
+
*
|
|
1501
|
+
* serializeLogicalCondition({ type: "or", conditions: [...] })
|
|
1502
|
+
* // → "or(status.eq.active,status.eq.pending)"
|
|
1503
|
+
*/
|
|
1504
|
+
function serializeLogicalCondition(cond) {
|
|
1505
|
+
if ("type" in cond) {
|
|
1506
|
+
const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
|
|
1507
|
+
return `${cond.type}(${inner})`;
|
|
1508
|
+
}
|
|
1509
|
+
return `${escapeWireValue(cond.column)}.${serializeOperatorAndValue(cond.operator, cond.value, {
|
|
1510
|
+
escapeScalar: true,
|
|
1511
|
+
where: "serializeLogicalCondition"
|
|
1512
|
+
})}`;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* The positions of the dots that can separate a leaf's segments.
|
|
1516
|
+
*
|
|
1517
|
+
* Dots inside a list value (`in.(1.5,2.5)`) are not separators. The value
|
|
1518
|
+
* always follows the operator, so the search only needs the region before the
|
|
1519
|
+
* first unescaped paren.
|
|
1520
|
+
*/
|
|
1521
|
+
function leafSeparators(str) {
|
|
1522
|
+
let limit = str.length;
|
|
1523
|
+
for (let i = 0; i < str.length; i++) {
|
|
1524
|
+
if (str[i] === "\\") {
|
|
1525
|
+
i++;
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
if (str[i] === "(") {
|
|
1529
|
+
limit = i;
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const dots = [];
|
|
1534
|
+
for (let i = 0; i < limit; i++) {
|
|
1535
|
+
if (str[i] === "\\") {
|
|
1536
|
+
i++;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (str[i] === ".") dots.push(i);
|
|
1540
|
+
}
|
|
1541
|
+
return dots;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Split a leaf condition into `column`, operator token and value.
|
|
1545
|
+
*
|
|
1546
|
+
* The naive reading — column is everything before the first dot, operator is
|
|
1547
|
+
* everything up to the second — cannot express a relation path. A filter on
|
|
1548
|
+
* `author.name` serializes to `author.name.eq.bob` and came back as the column
|
|
1549
|
+
* `author` with the operator `name`, which resolves to nothing, so the
|
|
1550
|
+
* fallback made it `author == "eq.bob"`: a condition that runs and matches
|
|
1551
|
+
* nothing, on a column the caller never named.
|
|
1552
|
+
*
|
|
1553
|
+
* So the operator is found rather than assumed: it is the first dot-separated
|
|
1554
|
+
* segment after the column that resolves to a real operator. Everything before
|
|
1555
|
+
* it is the column, everything after is the value. `version.eq.1.2.3` reads as
|
|
1556
|
+
* `version == "1.2.3"` because the scan stops at the first match — the `eq` at
|
|
1557
|
+
* offset 1, not a later segment — and `metadata->>x.eq.5` never had dots in the
|
|
1558
|
+
* column to begin with.
|
|
1559
|
+
*
|
|
1560
|
+
* Returns `undefined` when no segment resolves — `status.active`, an equality
|
|
1561
|
+
* written without an operator, which the caller handles.
|
|
1562
|
+
*/
|
|
1563
|
+
function splitLeafCondition(str) {
|
|
1564
|
+
const dots = leafSeparators(str);
|
|
1565
|
+
for (let i = 1; i < dots.length; i++) {
|
|
1566
|
+
const operator = toCanonicalOp(str.substring(dots[i - 1] + 1, dots[i]));
|
|
1567
|
+
if (!operator) continue;
|
|
1568
|
+
return {
|
|
1569
|
+
column: unescapeWireValue(str.substring(0, dots[i - 1])),
|
|
1570
|
+
operator,
|
|
1571
|
+
value: str.substring(dots[i] + 1)
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
function deserializeLogicalCondition(str, nesting = 0) {
|
|
1576
|
+
if (nesting > 32) throw new Error(`Filter groups nest more than 32 levels deep. Flatten the condition — \`or(a,or(b,c))\` is \`or(a,b,c)\`.`);
|
|
1577
|
+
const logicalMatch = str.match(/^(and|or|not)\((.+)\)$/);
|
|
1578
|
+
if (logicalMatch) {
|
|
1579
|
+
const type = logicalMatch[1];
|
|
1580
|
+
const innerStr = logicalMatch[2];
|
|
1581
|
+
return {
|
|
1582
|
+
type,
|
|
1583
|
+
conditions: splitGroupItems(innerStr).map((part) => deserializeLogicalCondition(part, nesting + 1))
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
const leaf = splitLeafCondition(str);
|
|
1587
|
+
if (!leaf) {
|
|
1588
|
+
const firstDot = str.indexOf(".");
|
|
1589
|
+
if (firstDot === -1) return {
|
|
1590
|
+
column: unescapeWireValue(str),
|
|
1591
|
+
operator: "==",
|
|
1592
|
+
value: true
|
|
1593
|
+
};
|
|
1594
|
+
const dots = leafSeparators(str);
|
|
1595
|
+
for (let i = 1; i < dots.length; i++) {
|
|
1596
|
+
const segment = str.substring(dots[i - 1] + 1, dots[i]);
|
|
1597
|
+
if (isOperatorShaped(segment)) throw new UnknownFilterOperatorError(unescapeWireValue(str.substring(0, dots[i - 1])), segment);
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
column: unescapeWireValue(str.substring(0, firstDot)),
|
|
1601
|
+
operator: "==",
|
|
1602
|
+
value: unescapeWireValue(str.substring(firstDot + 1))
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
const { column, operator, value: valueStr } = leaf;
|
|
1606
|
+
if (NULL_OPS.has(operator)) return {
|
|
1607
|
+
column,
|
|
1608
|
+
operator,
|
|
1609
|
+
value: null
|
|
1610
|
+
};
|
|
1611
|
+
if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
|
|
1612
|
+
const inner = valueStr.slice(1, -1);
|
|
1613
|
+
return {
|
|
1614
|
+
column,
|
|
1615
|
+
operator,
|
|
1616
|
+
value: inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
return {
|
|
1620
|
+
column,
|
|
1621
|
+
operator,
|
|
1622
|
+
value: unescapeWireValue(valueStr)
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
//#endregion
|
|
1626
|
+
export { isFieldOperation as C, Vector as D, GeoPoint as E, hasFieldOperation as S, EntityRelation as T, normalizeOrderBy as _, serializeFilter as a, BATCH_REF_KEY as b, collectAllPages as c, IncludeSpecError as d, deserializeInclude as f, OrderBySpecError as g, serializeInclude as h, deserializeLogicalCondition as i, paginateFind as l, normalizeInclude as m, UnknownFilterOperatorError as n, serializeLogicalCondition as o, mergeIncludeSpecs as p, deserializeFilter as r, PAGE_WITH_OFFSET_MESSAGE as s, RESERVED_QUERY_KEYS as t, resolveFindWindow as u, serializeOrderBy as v, EntityReference as w, FIELD_OPERATORS as x, withRelationReferencesOnly as y };
|
|
1627
|
+
|
|
1628
|
+
//# sourceMappingURL=filter-dialect-O1HK6Nvr.js.map
|