@rebasepro/server 0.19.1 → 0.19.2-canary.g08eed46
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/dist/{GCSStorageController-ZXPoNqW3.js → GCSStorageController-CLIJXwGS.js} +2 -2
- package/dist/{GCSStorageController-ZXPoNqW3.js.map → GCSStorageController-CLIJXwGS.js.map} +1 -1
- package/dist/{S3StorageController-5pAXyv31.js → S3StorageController-Dcuf8lMA.js} +2 -2
- package/dist/{S3StorageController-5pAXyv31.js.map → S3StorageController-Dcuf8lMA.js.map} +1 -1
- package/dist/api/errors.d.ts +6 -1
- package/dist/api/rest/api-generator.d.ts +80 -0
- package/dist/api/rest/batch.d.ts +71 -0
- package/dist/api/rest/conflict-target.d.ts +20 -0
- package/dist/api/rest/etag.d.ts +55 -0
- package/dist/api/rest/field-access-query.d.ts +66 -0
- package/dist/api/rest/field-ops.d.ts +86 -0
- package/dist/api/rest/query-parser.d.ts +16 -2
- package/dist/api/rest/soft-delete-params.d.ts +37 -0
- package/dist/api/rest/write-validation.d.ts +51 -8
- package/dist/api/types.d.ts +28 -3
- package/dist/{ast-schema-editor-CNgFJ3NF.js → ast-schema-editor-C6mDz0XN.js} +2 -2
- package/dist/{ast-schema-editor-CNgFJ3NF.js.map → ast-schema-editor-C6mDz0XN.js.map} +1 -1
- package/dist/auth/jwt.d.ts +17 -0
- package/dist/auth/rls-scope.d.ts +1 -0
- package/dist/{auth-BRiOuyq8.js → auth-DJsLXsCR.js} +61 -60
- package/dist/auth-DJsLXsCR.js.map +1 -0
- package/dist/{backup-C8P6Cl3G.js → backup-DGu0v9Ku.js} +2 -2
- package/dist/{backup-C8P6Cl3G.js.map → backup-DGu0v9Ku.js.map} +1 -1
- package/dist/boot/bundle.d.ts +12 -3
- package/dist/boot/driver.d.ts +11 -4
- package/dist/boot/env.d.ts +2 -2
- package/dist/{contract-routes-CusnEB5h.js → contract-routes-eLxV0le1.js} +2 -2
- package/dist/{contract-routes-CusnEB5h.js.map → contract-routes-eLxV0le1.js.map} +1 -1
- package/dist/{cron-loader-DnmIePn_.js → cron-loader-CQjvjpEw.js} +2 -2
- package/dist/{cron-loader-DnmIePn_.js.map → cron-loader-CQjvjpEw.js.map} +1 -1
- package/dist/{cron-routes-D3x2ydMa.js → cron-routes-Bfwni8Zg.js} +3 -3
- package/dist/{cron-routes-D3x2ydMa.js.map → cron-routes-Bfwni8Zg.js.map} +1 -1
- package/dist/{cron-store-CCQXwgVL.js → cron-store-Bsiw4Q6u.js} +3 -3
- package/dist/{cron-store-CCQXwgVL.js.map → cron-store-Bsiw4Q6u.js.map} +1 -1
- package/dist/{errors-HjfaPlvY.js → errors-DMImyqyR.js} +8 -3
- package/dist/errors-DMImyqyR.js.map +1 -0
- package/dist/{function-routes-gQ0EShVG.js → function-routes-C4nB2h0z.js} +2 -2
- package/dist/{function-routes-gQ0EShVG.js.map → function-routes-C4nB2h0z.js.map} +1 -1
- package/dist/functions/index.js +7 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-B1FwXx9J.js → history-recorder-r5_IzSHK.js} +2 -2
- package/dist/{history-recorder-B1FwXx9J.js.map → history-recorder-r5_IzSHK.js.map} +1 -1
- package/dist/{history-store-LHXaQywp.js → history-store-D4RVK-uZ.js} +2 -2
- package/dist/{history-store-LHXaQywp.js.map → history-store-D4RVK-uZ.js.map} +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +2429 -7303
- package/dist/index.es.js.map +1 -1
- package/dist/{jobs-DkkD9mPV.js → jobs-CW5lm_Ix.js} +3 -3
- package/dist/{jobs-DkkD9mPV.js.map → jobs-CW5lm_Ix.js.map} +1 -1
- package/dist/{jwt-DkhXwMzR.js → jwt-DATvkKB_.js} +39 -3
- package/dist/{jwt-DkhXwMzR.js.map → jwt-DATvkKB_.js.map} +1 -1
- package/dist/{keys-g8lbVC_o.js → keys-Qfc4XieN.js} +2 -2
- package/dist/{keys-g8lbVC_o.js.map → keys-Qfc4XieN.js.map} +1 -1
- package/dist/{logs-routes-CbsTpozn.js → logs-routes-DnJINsMu.js} +2 -2
- package/dist/{logs-routes-CbsTpozn.js.map → logs-routes-DnJINsMu.js.map} +1 -1
- package/dist/{openapi-generator-BIBbO1Tq.js → openapi-generator-DGyLbISS.js} +374 -48
- package/dist/openapi-generator-DGyLbISS.js.map +1 -0
- package/dist/{query-parser-C68Q9EX4.js → query-parser-BQiPZrM-.js} +257 -17
- package/dist/query-parser-BQiPZrM-.js.map +1 -0
- package/dist/{request-timeout-DESvlfrS.js → request-timeout-BR-OBwES.js} +2 -2
- package/dist/{request-timeout-DESvlfrS.js.map → request-timeout-BR-OBwES.js.map} +1 -1
- package/dist/{schema-editor-routes-CcZKh50q.js → schema-editor-routes-C3TLZqAC.js} +13 -13
- package/dist/{schema-editor-routes-CcZKh50q.js.map → schema-editor-routes-C3TLZqAC.js.map} +1 -1
- package/dist/{src-DHK4fHkw.js → src-Br6ARbs6.js} +26 -2
- package/dist/src-Br6ARbs6.js.map +1 -0
- package/dist/{src-Dq-I3Ybx.js → src-DqZ9YiGA.js} +1387 -94
- package/dist/src-DqZ9YiGA.js.map +1 -0
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/property-limits.d.ts +77 -0
- package/dist/storage/routes.d.ts +13 -0
- package/dist/storage/tus-handler.d.ts +22 -1
- package/package.json +7 -6
- package/dist/auth-BRiOuyq8.js.map +0 -1
- package/dist/errors-HjfaPlvY.js.map +0 -1
- package/dist/openapi-generator-BIBbO1Tq.js.map +0 -1
- package/dist/query-parser-C68Q9EX4.js.map +0 -1
- package/dist/schemas-C3234HWE.js +0 -6827
- package/dist/schemas-C3234HWE.js.map +0 -1
- package/dist/src-DHK4fHkw.js.map +0 -1
- package/dist/src-Dq-I3Ybx.js.map +0 -1
|
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { r as __require, t as __commonJSMin } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import { A as
|
|
6
|
+
import { A as LIST_OPS, C as getDeclaredSubcollections, D as getDataSourceCapabilities, F as sortKeyToString, I as toCanonicalOp, M as REST_TO_CANONICAL, N as isRelationAggregateSort, O as ALL_WHERE_FILTER_OPS, T as isRelationalCollectionConfig, j as NULL_OPS, k as CANONICAL_TO_REST, p as resolveResourceRefs, w as isPostgresCollectionConfig, x as rewriteLegacyRlsFunctions } from "./src-Br6ARbs6.js";
|
|
7
7
|
//#region ../types/src/errors.ts
|
|
8
8
|
/**
|
|
9
9
|
* The single error type thrown across the entire Rebase client surface —
|
|
@@ -363,8 +363,124 @@ var policy = {
|
|
|
363
363
|
value
|
|
364
364
|
}),
|
|
365
365
|
authUid: () => ({ kind: "authUid" }),
|
|
366
|
-
authRoles: () => ({ kind: "authRoles" })
|
|
366
|
+
authRoles: () => ({ kind: "authRoles" }),
|
|
367
|
+
authClaim: (name) => ({
|
|
368
|
+
kind: "authClaim",
|
|
369
|
+
name
|
|
370
|
+
})
|
|
367
371
|
};
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region ../types/src/types/tenancy.ts
|
|
374
|
+
/** Narrow a {@link TenantSource} to its claim form. @group Models */
|
|
375
|
+
function isTenantClaimSource(source) {
|
|
376
|
+
return typeof source.claim === "string";
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The roles tenancy does not apply to, when the collection names none.
|
|
380
|
+
*
|
|
381
|
+
* `admin`, mirroring the security baseline every collection already carries
|
|
382
|
+
* (`<table>_default_admin_read` / `_write`): the Studio, `dataAsAdmin` and a
|
|
383
|
+
* support operator all run with it, and a tenancy rule that locked them out
|
|
384
|
+
* would make the admin panel show an empty table on a collection full of rows.
|
|
385
|
+
*
|
|
386
|
+
* @group Models
|
|
387
|
+
*/
|
|
388
|
+
var DEFAULT_TENANT_BYPASS_ROLES = ["admin"];
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region ../types/src/controllers/data.ts
|
|
391
|
+
/**
|
|
392
|
+
* SDK collection client — returns flat rows, no Entity wrapper.
|
|
393
|
+
*
|
|
394
|
+
* This is the public API surface for app developers using
|
|
395
|
+
* `createRebaseClient()`. admin internals use `CollectionAccessor` instead.
|
|
396
|
+
*
|
|
397
|
+
* Type parameters:
|
|
398
|
+
* - `M` — the **Row** shape returned by reads (`find`, `findById`, `listen`).
|
|
399
|
+
* - `I` — the **Insert** shape accepted by {@link create}. Defaults to
|
|
400
|
+
* `Partial<M>`; the generated SDK supplies a dedicated `Insert` type where
|
|
401
|
+
* required columns are required and auto-generated / read-only columns are
|
|
402
|
+
* omitted, so `create({})` on a table with required fields is a compile error.
|
|
403
|
+
* - `U` — the **Update** shape accepted by {@link update}. Defaults to
|
|
404
|
+
* `Partial<M>`; the generated SDK supplies a dedicated `Update` type.
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* const { data: posts } = await rebase.data.posts.find();
|
|
408
|
+
* console.log(posts[0].title); // flat access
|
|
409
|
+
* console.log(posts[0].id); // id at top level
|
|
410
|
+
*
|
|
411
|
+
* const post = await rebase.data.posts.findById(1);
|
|
412
|
+
* console.log(post?.title); // no .values needed
|
|
413
|
+
*
|
|
414
|
+
* @group Data
|
|
415
|
+
*/
|
|
416
|
+
/**
|
|
417
|
+
* A change expressed as an operation on the column's current value, rather than
|
|
418
|
+
* as the value to store.
|
|
419
|
+
*
|
|
420
|
+
* `{ views: 5 }` says what the number becomes; `{ views: { $inc: 1 } }` says
|
|
421
|
+
* what happens to it. The difference is the read the caller no longer has to
|
|
422
|
+
* make — and the race that read opens. Two requests that each read `4`, add one
|
|
423
|
+
* and write `5` lose an increment between them; `SET views = views + 1` cannot,
|
|
424
|
+
* because the arithmetic happens inside the statement holding the row lock.
|
|
425
|
+
*
|
|
426
|
+
* Exactly one operator per field. `{ views: { $inc: 1, $push: "x" } }` is
|
|
427
|
+
* refused rather than applied in an order the caller cannot see.
|
|
428
|
+
*
|
|
429
|
+
* @group Data
|
|
430
|
+
*/
|
|
431
|
+
/**
|
|
432
|
+
* The operator names, as a value.
|
|
433
|
+
*
|
|
434
|
+
* A runtime list beside the type because three layers have to *recognise* an
|
|
435
|
+
* operation, not just accept one: the REST validator, the driver that compiles
|
|
436
|
+
* it, and the offline queue that must refuse to apply one locally. Three copies
|
|
437
|
+
* of four strings is three chances for one of them to miss an operator added to
|
|
438
|
+
* the other two, and the failure is silent in the worst direction — an
|
|
439
|
+
* unrecognised marker is written to the column as a JSON document.
|
|
440
|
+
*
|
|
441
|
+
* @group Data
|
|
442
|
+
*/
|
|
443
|
+
var FIELD_OPERATORS = [
|
|
444
|
+
"$inc",
|
|
445
|
+
"$push",
|
|
446
|
+
"$pull",
|
|
447
|
+
"$merge"
|
|
448
|
+
];
|
|
449
|
+
/**
|
|
450
|
+
* The key of a {@link BatchRef}. Declared here, beside the field operators,
|
|
451
|
+
* because the two share one namespace: a `$`-prefixed key in a write payload is
|
|
452
|
+
* a marker, and every reader of that namespace has to know all of it.
|
|
453
|
+
*
|
|
454
|
+
* @group Data
|
|
455
|
+
*/
|
|
456
|
+
var BATCH_REF_KEY = "$ref";
|
|
457
|
+
/**
|
|
458
|
+
* Whether a value is *trying* to be a field operation — including a misspelled
|
|
459
|
+
* one, which is the case worth catching.
|
|
460
|
+
*
|
|
461
|
+
* Any `$`-prefixed key counts, because `{ $increment: 1 }` written to a number
|
|
462
|
+
* column as a JSON document is the failure this exists to prevent. No collection
|
|
463
|
+
* can declare a column whose value legitimately has a key beginning with `$`: a
|
|
464
|
+
* `map` property's sub-keys are declared, and `$` is not valid in the
|
|
465
|
+
* identifiers the DDL generators emit.
|
|
466
|
+
*
|
|
467
|
+
* The one exception is `{ $ref: … }`, the batch's backward reference. It stands
|
|
468
|
+
* where a *value* goes and is resolved to one before the row is written, so it
|
|
469
|
+
* is not an operation on a column — reading it as a misspelled operator refused
|
|
470
|
+
* every `$ref` in a batch with "unknown field operator '$ref'".
|
|
471
|
+
*
|
|
472
|
+
* @group Data
|
|
473
|
+
*/
|
|
474
|
+
function isFieldOperation(value) {
|
|
475
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date) return false;
|
|
476
|
+
const keys = Object.keys(value);
|
|
477
|
+
if (keys.length === 1 && keys[0] === "$ref") return false;
|
|
478
|
+
return keys.some((key) => key.startsWith("$"));
|
|
479
|
+
}
|
|
480
|
+
/** True when any value in a write payload is (or is attempting to be) one. @group Data */
|
|
481
|
+
function hasFieldOperation(values) {
|
|
482
|
+
return !!values && Object.values(values).some(isFieldOperation);
|
|
483
|
+
}
|
|
368
484
|
/** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */
|
|
369
485
|
var MAX_LIST_LIMIT = 1e3;
|
|
370
486
|
/**
|
|
@@ -1469,6 +1585,16 @@ function removeFunctions(o) {
|
|
|
1469
1585
|
return o;
|
|
1470
1586
|
}
|
|
1471
1587
|
/**
|
|
1588
|
+
* Get a RegExp out of a serialized string
|
|
1589
|
+
* @param input
|
|
1590
|
+
*/
|
|
1591
|
+
function hydrateRegExp(input) {
|
|
1592
|
+
if (!input) return void 0;
|
|
1593
|
+
const fragments = input.match(/\/(.*?)\/([a-z]*)?$/i);
|
|
1594
|
+
if (fragments) return new RegExp(fragments[1], fragments[2] || "");
|
|
1595
|
+
else return new RegExp(input, "");
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1472
1598
|
* Returns the singular of an English word.
|
|
1473
1599
|
*
|
|
1474
1600
|
* @param {string} word
|
|
@@ -1823,7 +1949,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
|
|
|
1823
1949
|
through: {
|
|
1824
1950
|
table: relation.through?.table ?? [sourceTable, targetTable].sort().join("_"),
|
|
1825
1951
|
sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),
|
|
1826
|
-
targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName)
|
|
1952
|
+
targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),
|
|
1953
|
+
properties: relation.through?.properties ?? {}
|
|
1827
1954
|
}
|
|
1828
1955
|
};
|
|
1829
1956
|
}
|
|
@@ -2150,6 +2277,145 @@ function getSubcollections(collection) {
|
|
|
2150
2277
|
//#endregion
|
|
2151
2278
|
//#region ../common/src/util/policy/sqlToPolicy.ts
|
|
2152
2279
|
/**
|
|
2280
|
+
* A tiny, regex-based SQL "parser" for security rules.
|
|
2281
|
+
*
|
|
2282
|
+
* This is NOT a full SQL parser. It is designed to handle the subset of SQL
|
|
2283
|
+
* commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
|
|
2284
|
+
* optimistic client-side UI decision.
|
|
2285
|
+
*
|
|
2286
|
+
* It handles:
|
|
2287
|
+
* - `field = 'literal'`
|
|
2288
|
+
* - `field != 'literal'`
|
|
2289
|
+
* - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
|
|
2290
|
+
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
2291
|
+
* - `true`
|
|
2292
|
+
* - `IN (...)` (as optimistic true)
|
|
2293
|
+
*
|
|
2294
|
+
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
2295
|
+
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
2296
|
+
*
|
|
2297
|
+
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
2298
|
+
* schema/policy generators), so decomposing a clause the parser only partly
|
|
2299
|
+
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
2300
|
+
* prefer `raw`: it is reproduced verbatim.
|
|
2301
|
+
*/
|
|
2302
|
+
/** True when `keyword` starts at `i` as a standalone word. */
|
|
2303
|
+
function isKeywordAt(upper, i, keyword) {
|
|
2304
|
+
if (!upper.startsWith(keyword, i)) return false;
|
|
2305
|
+
const before = i === 0 ? " " : upper[i - 1];
|
|
2306
|
+
const after = upper[i + keyword.length] ?? " ";
|
|
2307
|
+
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
2311
|
+
* outside a string literal. Returns null when it never does, so the caller
|
|
2312
|
+
* leaves the clause alone.
|
|
2313
|
+
*
|
|
2314
|
+
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
2315
|
+
* `AND` inside
|
|
2316
|
+
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
|
|
2317
|
+
* split the expression, and re-emitting the halves produced
|
|
2318
|
+
* `(EXISTS (...) AND m.user_id = rebase.uid())`
|
|
2319
|
+
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
2320
|
+
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
2321
|
+
* clause as a `raw` expression, which round-trips verbatim.
|
|
2322
|
+
*/
|
|
2323
|
+
function splitTopLevel(sql, keyword) {
|
|
2324
|
+
const upper = sql.toUpperCase();
|
|
2325
|
+
const parts = [];
|
|
2326
|
+
let depth = 0;
|
|
2327
|
+
let inString = false;
|
|
2328
|
+
let start = 0;
|
|
2329
|
+
for (let i = 0; i < sql.length; i++) {
|
|
2330
|
+
const ch = sql[i];
|
|
2331
|
+
if (inString) {
|
|
2332
|
+
if (ch === "'") if (sql[i + 1] === "'") i++;
|
|
2333
|
+
else inString = false;
|
|
2334
|
+
continue;
|
|
2335
|
+
}
|
|
2336
|
+
if (ch === "'") {
|
|
2337
|
+
inString = true;
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
if (ch === "(") {
|
|
2341
|
+
depth++;
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
2344
|
+
if (ch === ")") {
|
|
2345
|
+
depth--;
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
2349
|
+
parts.push(sql.slice(start, i));
|
|
2350
|
+
i += keyword.length - 1;
|
|
2351
|
+
start = i + 1;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
if (parts.length === 0) return null;
|
|
2355
|
+
parts.push(sql.slice(start));
|
|
2356
|
+
const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
2357
|
+
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
2358
|
+
}
|
|
2359
|
+
/** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
|
|
2360
|
+
function stripOuterParens(sql) {
|
|
2361
|
+
let s = sql.trim();
|
|
2362
|
+
for (;;) {
|
|
2363
|
+
if (!s.startsWith("(") || !s.endsWith(")")) return s;
|
|
2364
|
+
let depth = 0;
|
|
2365
|
+
let inString = false;
|
|
2366
|
+
let wraps = true;
|
|
2367
|
+
for (let i = 0; i < s.length; i++) {
|
|
2368
|
+
const ch = s[i];
|
|
2369
|
+
if (inString) {
|
|
2370
|
+
if (ch === "'") if (s[i + 1] === "'") i++;
|
|
2371
|
+
else inString = false;
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
if (ch === "'") {
|
|
2375
|
+
inString = true;
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
if (ch === "(") depth++;
|
|
2379
|
+
else if (ch === ")") {
|
|
2380
|
+
depth--;
|
|
2381
|
+
if (depth === 0 && i < s.length - 1) {
|
|
2382
|
+
wraps = false;
|
|
2383
|
+
break;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
if (!wraps) return s;
|
|
2388
|
+
s = s.slice(1, -1).trim();
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
function sqlToPolicy(sql) {
|
|
2392
|
+
const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
|
|
2393
|
+
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
2394
|
+
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
2395
|
+
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
2396
|
+
if (overlapMatch) {
|
|
2397
|
+
const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2398
|
+
return policy.rolesOverlap(roles);
|
|
2399
|
+
}
|
|
2400
|
+
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
2401
|
+
if (containMatch) {
|
|
2402
|
+
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
2403
|
+
return policy.rolesContain(roles);
|
|
2404
|
+
}
|
|
2405
|
+
const orParts = splitTopLevel(trimmed, "OR");
|
|
2406
|
+
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
2407
|
+
const andParts = splitTopLevel(trimmed, "AND");
|
|
2408
|
+
if (andParts) return policy.and(...andParts.map(sqlToPolicy));
|
|
2409
|
+
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
2410
|
+
if (match) {
|
|
2411
|
+
const [, leftStr, op, rightStr] = match;
|
|
2412
|
+
const left = parseOperand(leftStr.trim());
|
|
2413
|
+
const right = parseOperand(rightStr.trim());
|
|
2414
|
+
if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
|
|
2415
|
+
}
|
|
2416
|
+
return policy.raw(trimmed);
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2153
2419
|
* Literals from other BaaS platforms that people compare `rebase.uid()` against
|
|
2154
2420
|
* out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
|
|
2155
2421
|
* `pgRoles`, one surface over: the same muscle memory inside a `using:` string
|
|
@@ -2178,6 +2444,87 @@ var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
|
|
|
2178
2444
|
["service_role", "Supabase"]
|
|
2179
2445
|
]);
|
|
2180
2446
|
new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
|
|
2447
|
+
function parseOperand(str) {
|
|
2448
|
+
if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
|
|
2449
|
+
const literal = parseSingleQuoted(str);
|
|
2450
|
+
if (literal !== null) return policy.literal(literal);
|
|
2451
|
+
if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
|
|
2452
|
+
if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
|
|
2453
|
+
if (/^true$/i.test(str)) return policy.literal(true);
|
|
2454
|
+
if (/^false$/i.test(str)) return policy.literal(false);
|
|
2455
|
+
if (/^null$/i.test(str)) return policy.literal(null);
|
|
2456
|
+
if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
|
|
2457
|
+
return null;
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Decode a single-quoted SQL literal, or null when `str` is not exactly one.
|
|
2461
|
+
*
|
|
2462
|
+
* Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
|
|
2463
|
+
* operator, not one literal whose body contains a quote, and a regex anchored
|
|
2464
|
+
* on the outer quotes would happily read it as the latter. Every interior quote
|
|
2465
|
+
* must therefore be part of a `''` pair.
|
|
2466
|
+
*/
|
|
2467
|
+
function parseSingleQuoted(str) {
|
|
2468
|
+
if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
|
|
2469
|
+
const body = str.slice(1, -1);
|
|
2470
|
+
let out = "";
|
|
2471
|
+
for (let i = 0; i < body.length; i++) {
|
|
2472
|
+
if (body[i] !== "'") {
|
|
2473
|
+
out += body[i];
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
if (body[i + 1] === "'") {
|
|
2477
|
+
out += "'";
|
|
2478
|
+
i++;
|
|
2479
|
+
continue;
|
|
2480
|
+
}
|
|
2481
|
+
return null;
|
|
2482
|
+
}
|
|
2483
|
+
return out;
|
|
2484
|
+
}
|
|
2485
|
+
//#endregion
|
|
2486
|
+
//#region ../common/src/util/policy/securityRuleToConditions.ts
|
|
2487
|
+
/**
|
|
2488
|
+
* Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
|
|
2489
|
+
* structured `condition`/`check`, and raw `using`/`withCheck` — into a single
|
|
2490
|
+
* normalized {@link PolicyExpression} pair.
|
|
2491
|
+
*
|
|
2492
|
+
* **This is the linchpin against drift:** both the Postgres DDL generators and
|
|
2493
|
+
* the client-side evaluator consume this one function, so there is exactly one
|
|
2494
|
+
* definition of what a rule means. In particular, application `roles` are folded
|
|
2495
|
+
* into the expression here (AND'd with the base condition, matching how Postgres
|
|
2496
|
+
* generates the clause) rather than being handled separately by each consumer.
|
|
2497
|
+
*/
|
|
2498
|
+
function securityRuleToConditions(rule) {
|
|
2499
|
+
return {
|
|
2500
|
+
usingExpr: withRoles(baseUsing(rule), rule),
|
|
2501
|
+
withCheckExpr: withRoles(baseWithCheck(rule), rule)
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
function baseUsing(rule) {
|
|
2505
|
+
if (rule.condition) return rule.condition;
|
|
2506
|
+
if (rule.using != null) return sqlToPolicy(rule.using);
|
|
2507
|
+
if (rule.access === "public") return policy.true();
|
|
2508
|
+
if (rule.ownerField) return policy.compare(policy.field(rule.ownerField), "eq", policy.authUid());
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
function baseWithCheck(rule) {
|
|
2512
|
+
if (rule.check) return rule.check;
|
|
2513
|
+
if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
|
|
2514
|
+
return baseUsing(rule);
|
|
2515
|
+
}
|
|
2516
|
+
/**
|
|
2517
|
+
* AND the base condition with an application-role check, or produce a roles-only
|
|
2518
|
+
* condition when there is no base. Mirrors the Postgres generator so that a
|
|
2519
|
+
* role-scoped restrictive rule denies exactly the same set of users on both
|
|
2520
|
+
* sides.
|
|
2521
|
+
*/
|
|
2522
|
+
function withRoles(base, rule) {
|
|
2523
|
+
if (!rule.roles || rule.roles.length === 0) return base;
|
|
2524
|
+
const rolesExpr = policy.rolesOverlap(rule.roles);
|
|
2525
|
+
if (rule.mode === "restrictive") return base ? policy.or(policy.not(rolesExpr), base) : policy.not(rolesExpr);
|
|
2526
|
+
return base ? policy.and(base, rolesExpr) : rolesExpr;
|
|
2527
|
+
}
|
|
2181
2528
|
//#endregion
|
|
2182
2529
|
//#region ../common/src/util/builders.ts
|
|
2183
2530
|
/**
|
|
@@ -2190,6 +2537,103 @@ function defineCollection(collection) {
|
|
|
2190
2537
|
return resolveResourceRefs(collection);
|
|
2191
2538
|
}
|
|
2192
2539
|
//#endregion
|
|
2540
|
+
//#region ../common/src/util/tenant.ts
|
|
2541
|
+
/**
|
|
2542
|
+
* The one reading of `collection.tenant`.
|
|
2543
|
+
*
|
|
2544
|
+
* Four things have to agree for a tenant-scoped collection to work — the
|
|
2545
|
+
* column, the RLS policy, the value stamped on insert and the index — and
|
|
2546
|
+
* before this they were four hand-written declarations that nothing compared.
|
|
2547
|
+
* The three that are schema become a {@link SecurityRule} and a column effect
|
|
2548
|
+
* derived here and in `planSchema`; the fourth, the write path, is
|
|
2549
|
+
* {@link resolveTenantWrite}.
|
|
2550
|
+
*
|
|
2551
|
+
* Everything in this module is pure. The policy it builds is a `SecurityRule`
|
|
2552
|
+
* like any other, which is what makes `db push`, the doctor, boot-ensure, the
|
|
2553
|
+
* drift detector and the Studio treat the tenancy policy as what it is —
|
|
2554
|
+
* generated, named, and recognisable — rather than as somebody's hand-written
|
|
2555
|
+
* SQL that a push should offer to drop.
|
|
2556
|
+
*/
|
|
2557
|
+
/**
|
|
2558
|
+
* The collection's tenancy declaration, or nothing.
|
|
2559
|
+
*
|
|
2560
|
+
* Read through this rather than off the object, so the one shape check —
|
|
2561
|
+
* `tenant` is an object carrying a `field` and a `from` — is in one place. A
|
|
2562
|
+
* config that is *wrong* is refused by `validateCollectionConfig` with a
|
|
2563
|
+
* message; this is only asking whether there is one.
|
|
2564
|
+
*/
|
|
2565
|
+
function getTenantConfig(collection) {
|
|
2566
|
+
const tenant = collection?.tenant;
|
|
2567
|
+
if (!tenant || typeof tenant !== "object") return void 0;
|
|
2568
|
+
if (typeof tenant.field !== "string" || !tenant.field) return void 0;
|
|
2569
|
+
if (!tenant.from || typeof tenant.from !== "object") return void 0;
|
|
2570
|
+
return tenant;
|
|
2571
|
+
}
|
|
2572
|
+
/** The roles tenancy does not apply to, defaulted. */
|
|
2573
|
+
function tenantBypassRoles(tenant) {
|
|
2574
|
+
return tenant.bypassRoles ?? DEFAULT_TENANT_BYPASS_ROLES;
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* The name of the policy a tenant declaration compiles to.
|
|
2578
|
+
*
|
|
2579
|
+
* Explicit — not a `getPolicyNameHash` of the rule — precisely because the
|
|
2580
|
+
* rule's *body* is compiled with more information in some callers than in
|
|
2581
|
+
* others (`planSchema` can resolve a relation's target collection and so knows
|
|
2582
|
+
* the column's type; the Studio, asking only for names, cannot). A hashed name
|
|
2583
|
+
* would then differ between the two, and the same policy would read as drift.
|
|
2584
|
+
* A frozen identifier: see `contracts/derived-names.txt`.
|
|
2585
|
+
*/
|
|
2586
|
+
function tenantPolicyName(tableName) {
|
|
2587
|
+
return `${tableName}_tenant_scope`;
|
|
2588
|
+
}
|
|
2589
|
+
/**
|
|
2590
|
+
* The condition a tenant declaration means, as a policy expression.
|
|
2591
|
+
*
|
|
2592
|
+
* `serverContext()` first, for the same reason every injected baseline rule
|
|
2593
|
+
* carries it: the trusted plane runs migrations, the auth flows and the boot,
|
|
2594
|
+
* and a restrictive policy that excluded it would not protect a tenant, it
|
|
2595
|
+
* would stop the server from starting.
|
|
2596
|
+
*
|
|
2597
|
+
* Then the bypass roles, then the tenancy test itself — a claim comparison or a
|
|
2598
|
+
* correlated `EXISTS` over the membership table, which are the two ways a
|
|
2599
|
+
* deployment answers "which tenant is this caller in".
|
|
2600
|
+
*/
|
|
2601
|
+
function tenantScopeExpression(tenant) {
|
|
2602
|
+
const match = isTenantClaimSource(tenant.from) ? policy.compare(policy.field(tenant.field), "eq", policy.authClaim(tenant.from.claim)) : policy.existsIn({
|
|
2603
|
+
collection: tenant.from.membership.collection,
|
|
2604
|
+
where: policy.and(policy.compare(policy.field(tenant.from.membership.tenantField), "eq", policy.outerField(tenant.field)), policy.compare(policy.field(tenant.from.membership.userField), "eq", policy.authUid()))
|
|
2605
|
+
});
|
|
2606
|
+
const bypass = tenantBypassRoles(tenant);
|
|
2607
|
+
return bypass.length > 0 ? policy.or(policy.serverContext(), policy.rolesOverlap(bypass), match) : policy.or(policy.serverContext(), match);
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* The rule a tenant declaration compiles to, or nothing when there is none.
|
|
2611
|
+
*
|
|
2612
|
+
* **Restrictive**, and that is the whole design. A restrictive policy is ANDed
|
|
2613
|
+
* with every other policy on the table, so tenancy narrows what the
|
|
2614
|
+
* collection's own `securityRules` allow and can never widen it. A permissive
|
|
2615
|
+
* one would OR with them, and a single `access: "public"` rule elsewhere in the
|
|
2616
|
+
* file would take the entire tenancy boundary off without contradicting
|
|
2617
|
+
* anything a reader could see.
|
|
2618
|
+
*
|
|
2619
|
+
* One rule with `operation: "all"` rather than four with `operations: [...]`:
|
|
2620
|
+
* `FOR ALL` gives Postgres the USING clause for SELECT/UPDATE/DELETE and the
|
|
2621
|
+
* WITH CHECK clause for INSERT/UPDATE, which is exactly the coverage wanted,
|
|
2622
|
+
* as one policy with one name instead of four.
|
|
2623
|
+
*/
|
|
2624
|
+
function buildTenantSecurityRule(collection) {
|
|
2625
|
+
const tenant = getTenantConfig(collection);
|
|
2626
|
+
if (!tenant) return void 0;
|
|
2627
|
+
const expression = tenantScopeExpression(tenant);
|
|
2628
|
+
return {
|
|
2629
|
+
name: tenantPolicyName(getTableName(collection)),
|
|
2630
|
+
mode: "restrictive",
|
|
2631
|
+
operation: "all",
|
|
2632
|
+
condition: expression,
|
|
2633
|
+
check: expression
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
//#endregion
|
|
2193
2637
|
//#region ../common/src/util/auth-default-policies.ts
|
|
2194
2638
|
/**
|
|
2195
2639
|
* Default RLS policies injected by the schema generator.
|
|
@@ -2234,8 +2678,16 @@ function defineCollection(collection) {
|
|
|
2234
2678
|
* FORCE RLS. A *user* request never reaches that state: an anonymous one carries
|
|
2235
2679
|
* `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
|
|
2236
2680
|
*
|
|
2681
|
+
* **For a collection declaring `tenant`, additionally**
|
|
2682
|
+
* 5. A **restrictive** tenancy gate for every operation. Same kind of thing as
|
|
2683
|
+
* the admin write gate and injected for the same reason: it is ANDed with
|
|
2684
|
+
* every other policy, so it narrows what the author's permissive rules
|
|
2685
|
+
* grant and can never widen them. See `./tenant.ts`.
|
|
2686
|
+
*
|
|
2237
2687
|
* Opt out with `disableDefaultPolicies: true` to take full responsibility for
|
|
2238
|
-
* the collection's RLS.
|
|
2688
|
+
* the collection's RLS. The *restrictive* rules are not part of that opt-out:
|
|
2689
|
+
* dropping a rule that can only remove access could express nothing but "let
|
|
2690
|
+
* more people in", which is what the flag already does by removing the grants.
|
|
2239
2691
|
*/
|
|
2240
2692
|
var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
2241
2693
|
/** Write operations that must be admin-gated by default on auth collections. */
|
|
@@ -2279,11 +2731,27 @@ function adminWriteGate(tableName) {
|
|
|
2279
2731
|
check: SERVER_OR_ADMIN_EXPR$1
|
|
2280
2732
|
};
|
|
2281
2733
|
}
|
|
2734
|
+
/**
|
|
2735
|
+
* The restrictive tenancy policy, as a list of zero or one.
|
|
2736
|
+
*
|
|
2737
|
+
* A list so the two call sites can splice it in without a conditional, and a
|
|
2738
|
+
* separate function so it is obvious that it is injected on *both* paths —
|
|
2739
|
+
* including the `disableDefaultPolicies` one, where it is the only permissive-
|
|
2740
|
+
* looking thing that stays. See `./tenant.ts`.
|
|
2741
|
+
*/
|
|
2742
|
+
function tenantRule(collection) {
|
|
2743
|
+
const rule = buildTenantSecurityRule(collection);
|
|
2744
|
+
return rule ? [rule] : [];
|
|
2745
|
+
}
|
|
2282
2746
|
function getEffectiveSecurityRules(collection) {
|
|
2283
2747
|
const explicit = [...collection.securityRules ?? []];
|
|
2284
2748
|
const tableName = getTableName(collection);
|
|
2285
2749
|
const injected = [];
|
|
2286
|
-
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return
|
|
2750
|
+
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return [
|
|
2751
|
+
...explicit,
|
|
2752
|
+
...tenantRule(collection),
|
|
2753
|
+
...isAuthCollection(collection) ? [adminWriteGate(tableName)] : []
|
|
2754
|
+
];
|
|
2287
2755
|
injected.push({
|
|
2288
2756
|
name: `${tableName}_default_admin_read`,
|
|
2289
2757
|
operations: ["select"],
|
|
@@ -2303,6 +2771,7 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2303
2771
|
});
|
|
2304
2772
|
injected.push(adminWriteGate(tableName));
|
|
2305
2773
|
}
|
|
2774
|
+
injected.push(...tenantRule(collection));
|
|
2306
2775
|
return [...explicit, ...injected];
|
|
2307
2776
|
}
|
|
2308
2777
|
policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
@@ -3047,6 +3516,35 @@ function resolveDataSource(collection, registry) {
|
|
|
3047
3516
|
capabilities: getDataSourceCapabilities(engine)
|
|
3048
3517
|
};
|
|
3049
3518
|
}
|
|
3519
|
+
/**
|
|
3520
|
+
* Does a SQL toolchain own this collection's storage?
|
|
3521
|
+
*
|
|
3522
|
+
* "Owns the storage" means: something generates a table for it, pushes that
|
|
3523
|
+
* table to a database, plans its RLS policies, and reports it as drifted when
|
|
3524
|
+
* the two disagree. That is true of a Postgres collection and false of a
|
|
3525
|
+
* Firestore or MongoDB one, whose documents live in a store Rebase never
|
|
3526
|
+
* migrates — and the two were never told apart. Every stage of the SQL
|
|
3527
|
+
* toolchain took "the collections" to mean *all* of them, so a Firestore
|
|
3528
|
+
* collection declared next to the Postgres ones got a `pgTable` in the
|
|
3529
|
+
* generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
|
|
3530
|
+
* `db push` include list — where its name shielding a same-named real table
|
|
3531
|
+
* from Atlas's exclude list is the one that can lose data.
|
|
3532
|
+
*
|
|
3533
|
+
* The answer is the resolved engine's {@link DataSourceCapabilities}, not a
|
|
3534
|
+
* name check: an engine registered through `registerDataSourceCapabilities`
|
|
3535
|
+
* gets the same treatment as the built-in ones.
|
|
3536
|
+
*
|
|
3537
|
+
* Deliberately answers **true** for an engine nobody has heard of. Build-time
|
|
3538
|
+
* tooling (the CLI, the schema generator) has no data-source registry to
|
|
3539
|
+
* resolve a `dataSource` key against, so an unknown key resolves to an unknown
|
|
3540
|
+
* engine — and the cost of the two mistakes is not symmetric. Wrongly
|
|
3541
|
+
* including a collection generates a table nothing writes to; wrongly excluding
|
|
3542
|
+
* one silently stops generating a table the app is serving from. Declare
|
|
3543
|
+
* `engine` on a collection that is not SQL-backed and this is exact.
|
|
3544
|
+
*/
|
|
3545
|
+
function isRelationalCollection(collection, registry) {
|
|
3546
|
+
return getDataSourceCapabilities(collection?.engine ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : void 0)).supportsRelations;
|
|
3547
|
+
}
|
|
3050
3548
|
//#endregion
|
|
3051
3549
|
//#region ../common/src/collections/CollectionRegistry.ts
|
|
3052
3550
|
var CollectionRegistry = class {
|
|
@@ -3404,6 +3902,205 @@ var defaultUsersCollection = defineCollection({
|
|
|
3404
3902
|
}
|
|
3405
3903
|
}
|
|
3406
3904
|
});
|
|
3905
|
+
/**
|
|
3906
|
+
* What a property's access rules actually are, with `excludeFromApi` expanded.
|
|
3907
|
+
*
|
|
3908
|
+
* Returns `undefined` when the property constrains nothing, so callers can skip
|
|
3909
|
+
* the whole check for the overwhelmingly common case.
|
|
3910
|
+
*/
|
|
3911
|
+
function effectiveAccess(property) {
|
|
3912
|
+
if (!property) return void 0;
|
|
3913
|
+
if (property.excludeFromApi) return EXCLUDED_ACCESS;
|
|
3914
|
+
const access = property.access;
|
|
3915
|
+
if (!access) return void 0;
|
|
3916
|
+
if (access.read === void 0 && access.write === void 0) return void 0;
|
|
3917
|
+
return access;
|
|
3918
|
+
}
|
|
3919
|
+
/** The rule `excludeFromApi: true` expands to. Frozen: it is shared by every caller. */
|
|
3920
|
+
var EXCLUDED_ACCESS = Object.freeze({
|
|
3921
|
+
read: Object.freeze([]),
|
|
3922
|
+
write: Object.freeze([])
|
|
3923
|
+
});
|
|
3924
|
+
/**
|
|
3925
|
+
* Does a caller holding `roles` satisfy `allowed`?
|
|
3926
|
+
*
|
|
3927
|
+
* Three cases, and the middle one is the one worth stating out loud:
|
|
3928
|
+
*
|
|
3929
|
+
* - `allowed` omitted — the field carries no rule of its own, so the row's
|
|
3930
|
+
* policies have already answered. True.
|
|
3931
|
+
* - `allowed` empty — nobody, at any privilege, through any API. Not the admin,
|
|
3932
|
+
* not the service key, not the trusted plane reading on a caller's behalf.
|
|
3933
|
+
* This is what `excludeFromApi` has always meant on the read side, and
|
|
3934
|
+
* collapsing the two spellings means the empty list has to keep meaning it.
|
|
3935
|
+
* - `allowed` non-empty — one of the named roles, or `admin`, or no viewer at
|
|
3936
|
+
* all (the trusted server plane, which is not an API caller).
|
|
3937
|
+
*/
|
|
3938
|
+
function satisfies(allowed, viewer) {
|
|
3939
|
+
if (allowed === void 0) return true;
|
|
3940
|
+
if (allowed.length === 0) return false;
|
|
3941
|
+
if (!viewer) return true;
|
|
3942
|
+
const roles = viewer.roles;
|
|
3943
|
+
if (!roles || roles.length === 0) return false;
|
|
3944
|
+
return roles.includes("admin") || allowed.some((role) => roles.includes(role));
|
|
3945
|
+
}
|
|
3946
|
+
/** May this caller receive this field's value? */
|
|
3947
|
+
function canReadField(property, viewer) {
|
|
3948
|
+
const access = effectiveAccess(property);
|
|
3949
|
+
return access ? satisfies(access.read, viewer) : true;
|
|
3950
|
+
}
|
|
3951
|
+
/** May this caller set this field's value? */
|
|
3952
|
+
function canWriteField(property, viewer) {
|
|
3953
|
+
const access = effectiveAccess(property);
|
|
3954
|
+
return access ? satisfies(access.write, viewer) : true;
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* The names on this collection a caller may not touch, in the two spellings a
|
|
3958
|
+
* caller can write them in.
|
|
3959
|
+
*
|
|
3960
|
+
* `declared` is the property keys, which is what has to leave a *known-fields*
|
|
3961
|
+
* set. `refused` is those plus the physical column names behind them: a caller
|
|
3962
|
+
* who knows the table can send `password_hash` as readily as `passwordHash`, and
|
|
3963
|
+
* a rule that only knew the wire name would be one rename away from useless.
|
|
3964
|
+
*
|
|
3965
|
+
* `kind` picks which half of the rule is read; nothing else differs.
|
|
3966
|
+
*/
|
|
3967
|
+
function restrictedFieldNames(collection, viewer, kind) {
|
|
3968
|
+
const declared = [];
|
|
3969
|
+
const refused = /* @__PURE__ */ new Set();
|
|
3970
|
+
const allowed = kind === "read" ? canReadField : canWriteField;
|
|
3971
|
+
for (const [name, property] of Object.entries(collection.properties ?? {})) {
|
|
3972
|
+
if (allowed(property, viewer)) continue;
|
|
3973
|
+
declared.push(name);
|
|
3974
|
+
refused.add(name);
|
|
3975
|
+
const columnName = property.columnName;
|
|
3976
|
+
if (columnName) refused.add(columnName);
|
|
3977
|
+
}
|
|
3978
|
+
return {
|
|
3979
|
+
declared,
|
|
3980
|
+
refused
|
|
3981
|
+
};
|
|
3982
|
+
}
|
|
3983
|
+
//#endregion
|
|
3984
|
+
//#region ../common/src/data/cursor.ts
|
|
3985
|
+
/** A cursor that cannot be read at all — truncated, re-encoded, or invented. */
|
|
3986
|
+
var CursorError = class CursorError extends Error {
|
|
3987
|
+
code = "INVALID_CURSOR";
|
|
3988
|
+
constructor(detail) {
|
|
3989
|
+
super(`Invalid \`after\` cursor: ${detail}. Pass back the \`meta.nextCursor\` from the previous page unchanged — it is opaque and must not be built by hand.`);
|
|
3990
|
+
this.name = "CursorError";
|
|
3991
|
+
Object.setPrototypeOf(this, CursorError.prototype);
|
|
3992
|
+
}
|
|
3993
|
+
};
|
|
3994
|
+
/**
|
|
3995
|
+
* A cursor that reads fine but describes a different query.
|
|
3996
|
+
*
|
|
3997
|
+
* Separate from {@link CursorError} because the fix is different: this one is
|
|
3998
|
+
* not a corrupt string, it is a correct cursor used against a sort it was not
|
|
3999
|
+
* produced under. Seeking anyway would return rows in an order nobody asked
|
|
4000
|
+
* for, and — worse — would look like it worked.
|
|
4001
|
+
*/
|
|
4002
|
+
var CursorMismatchError = class CursorMismatchError extends Error {
|
|
4003
|
+
code = "CURSOR_ORDER_MISMATCH";
|
|
4004
|
+
constructor(cursorKeys, queryKeys) {
|
|
4005
|
+
super(`The \`after\` cursor was produced by a query ordered by ${cursorKeys.map((k) => `"${k}"`).join(", ") || "(nothing)"}, but this query orders by ${queryKeys.map((k) => `"${k}"`).join(", ") || "(nothing)"}. A cursor only continues the listing it came from — keep \`orderBy\` identical across pages, or drop \`after\` to start over.`);
|
|
4006
|
+
this.name = "CursorMismatchError";
|
|
4007
|
+
Object.setPrototypeOf(this, CursorMismatchError.prototype);
|
|
4008
|
+
}
|
|
4009
|
+
};
|
|
4010
|
+
/**
|
|
4011
|
+
* Tag for a value whose JSON round-trip would otherwise lose its type.
|
|
4012
|
+
*
|
|
4013
|
+
* A `timestamp` column comes back from the driver as a `Date`; JSON turns it
|
|
4014
|
+
* into a string, and the string would then be compared against the column by
|
|
4015
|
+
* whatever cast Postgres chose. Round-tripping it as a `Date` keeps the
|
|
4016
|
+
* comparison the one the ORDER BY made.
|
|
4017
|
+
*/
|
|
4018
|
+
var DATE_TAG = "$date";
|
|
4019
|
+
function decodeValue(value) {
|
|
4020
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4021
|
+
const tagged = value[DATE_TAG];
|
|
4022
|
+
if (typeof tagged === "string") {
|
|
4023
|
+
const date = new Date(tagged);
|
|
4024
|
+
return Number.isNaN(date.getTime()) ? tagged : date;
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
return value;
|
|
4028
|
+
}
|
|
4029
|
+
function fromBase64Url(encoded) {
|
|
4030
|
+
const padded = encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - encoded.length % 4) % 4);
|
|
4031
|
+
const binary = atob(padded);
|
|
4032
|
+
const bytes = new Uint8Array(binary.length);
|
|
4033
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
4034
|
+
return new TextDecoder().decode(bytes);
|
|
4035
|
+
}
|
|
4036
|
+
/**
|
|
4037
|
+
* Read a cursor produced by {@link encodeCursor}.
|
|
4038
|
+
*
|
|
4039
|
+
* @throws {CursorError} when the string is not a cursor this codec wrote.
|
|
4040
|
+
*/
|
|
4041
|
+
function decodeCursor(raw) {
|
|
4042
|
+
let parsed;
|
|
4043
|
+
try {
|
|
4044
|
+
parsed = JSON.parse(fromBase64Url(raw.trim()));
|
|
4045
|
+
} catch {
|
|
4046
|
+
throw new CursorError("it is not a cursor this API issued");
|
|
4047
|
+
}
|
|
4048
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new CursorError("it does not decode to a cursor");
|
|
4049
|
+
const body = parsed;
|
|
4050
|
+
if (!Array.isArray(body.k)) throw new CursorError("it carries no sort keys");
|
|
4051
|
+
if (body.i === void 0) throw new CursorError("it carries no row id");
|
|
4052
|
+
const orderBy = [];
|
|
4053
|
+
for (const entry of body.k) {
|
|
4054
|
+
if (!Array.isArray(entry) || typeof entry[0] !== "string") throw new CursorError("one of its sort keys is malformed");
|
|
4055
|
+
const direction = entry[1] === "desc" ? "desc" : "asc";
|
|
4056
|
+
orderBy.push(entry[2] === "first" || entry[2] === "last" ? [
|
|
4057
|
+
entry[0],
|
|
4058
|
+
direction,
|
|
4059
|
+
entry[2]
|
|
4060
|
+
] : [entry[0], direction]);
|
|
4061
|
+
}
|
|
4062
|
+
const rawValues = body.v && typeof body.v === "object" && !Array.isArray(body.v) ? body.v : {};
|
|
4063
|
+
const values = {};
|
|
4064
|
+
for (const [field, value] of Object.entries(rawValues)) values[field] = decodeValue(value);
|
|
4065
|
+
return {
|
|
4066
|
+
orderBy,
|
|
4067
|
+
values,
|
|
4068
|
+
id: decodeValue(body.i)
|
|
4069
|
+
};
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* The `orderBy` a request should run under, given a cursor and whatever sort
|
|
4073
|
+
* the request itself named.
|
|
4074
|
+
*
|
|
4075
|
+
* A request that names no sort **adopts the cursor's** — that is what makes
|
|
4076
|
+
* `find({ after })` work without restating the `orderBy` from the previous
|
|
4077
|
+
* call, and it cannot be wrong, since the cursor is the only sort in play.
|
|
4078
|
+
* A request that names one must name the *same* one, key for key, direction for
|
|
4079
|
+
* direction, nulls for nulls; anything else is {@link CursorMismatchError}.
|
|
4080
|
+
*
|
|
4081
|
+
* @throws {CursorMismatchError}
|
|
4082
|
+
*/
|
|
4083
|
+
function reconcileCursorOrder(cursor, requested) {
|
|
4084
|
+
if (!requested || requested.length === 0) return cursor.orderBy;
|
|
4085
|
+
const spell = (keys) => keys.map(([field, direction, nulls]) => `${field}:${direction}${nulls ? `:${nulls}` : ""}`);
|
|
4086
|
+
const cursorKeys = spell(cursor.orderBy);
|
|
4087
|
+
const queryKeys = spell(requested);
|
|
4088
|
+
if (cursorKeys.length !== queryKeys.length || cursorKeys.some((key, i) => key !== queryKeys[i])) throw new CursorMismatchError(cursorKeys, queryKeys);
|
|
4089
|
+
return requested;
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* The `startAfter` shape the driver contract takes, built from a cursor.
|
|
4093
|
+
*
|
|
4094
|
+
* The driver has always accepted `{ id, values }`; this is the one place that
|
|
4095
|
+
* shape is produced, so the REST route and the WebSocket ingress cannot drift
|
|
4096
|
+
* into two spellings of the same seek.
|
|
4097
|
+
*/
|
|
4098
|
+
function cursorToStartAfter(cursor) {
|
|
4099
|
+
return {
|
|
4100
|
+
id: cursor.id,
|
|
4101
|
+
values: cursor.values
|
|
4102
|
+
};
|
|
4103
|
+
}
|
|
3407
4104
|
//#endregion
|
|
3408
4105
|
//#region ../common/src/data/sort-dialect.ts
|
|
3409
4106
|
/**
|
|
@@ -3450,13 +4147,24 @@ var OrderBySpecError = class extends Error {
|
|
|
3450
4147
|
this.name = "OrderBySpecError";
|
|
3451
4148
|
}
|
|
3452
4149
|
};
|
|
4150
|
+
/** `first`/`last`, or a refusal naming the entry — see {@link NullsPlacement}. */
|
|
4151
|
+
function toStrictNulls(raw, index) {
|
|
4152
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
4153
|
+
if (raw !== "first" && raw !== "last") throw new OrderBySpecError(`entry ${index} has nulls '${String(raw)}' — expected "first" or "last"`);
|
|
4154
|
+
return raw;
|
|
4155
|
+
}
|
|
3453
4156
|
function toStrictTuple(raw, index) {
|
|
3454
4157
|
if (!Array.isArray(raw)) throw new OrderBySpecError(`entry ${index} has no field name`);
|
|
3455
4158
|
const key = isRelationAggregateSort(raw[0]) ? sortKeyToString(raw[0]) : raw[0];
|
|
3456
4159
|
if (typeof key !== "string" || key.trim() === "") throw new OrderBySpecError(`entry ${index} has no field name`);
|
|
3457
4160
|
const direction = raw[1];
|
|
3458
4161
|
if (direction !== void 0 && direction !== "asc" && direction !== "desc") throw new OrderBySpecError(`entry ${index} has direction '${String(direction)}'`);
|
|
3459
|
-
|
|
4162
|
+
const nulls = toStrictNulls(raw[2], index);
|
|
4163
|
+
return nulls ? [
|
|
4164
|
+
key,
|
|
4165
|
+
direction ?? "asc",
|
|
4166
|
+
nulls
|
|
4167
|
+
] : [key, direction ?? "asc"];
|
|
3460
4168
|
}
|
|
3461
4169
|
/**
|
|
3462
4170
|
* Serialize a sort to the wire.
|
|
@@ -3486,11 +4194,360 @@ function serializeOrderBy(orderBy) {
|
|
|
3486
4194
|
if (typeof orderBy === "string") return orderBy;
|
|
3487
4195
|
const list = normalizeOrderBy(orderBy);
|
|
3488
4196
|
if (!list) return void 0;
|
|
3489
|
-
if (list.length === 1)
|
|
3490
|
-
|
|
4197
|
+
if (list.length === 1) {
|
|
4198
|
+
const [field, direction, nulls] = list[0];
|
|
4199
|
+
return nulls ? `${field}:${direction}:${nulls}` : `${field}:${direction}`;
|
|
4200
|
+
}
|
|
4201
|
+
return JSON.stringify(list.map(([field, direction, nulls]) => nulls ? {
|
|
4202
|
+
field,
|
|
4203
|
+
direction,
|
|
4204
|
+
nulls
|
|
4205
|
+
} : {
|
|
3491
4206
|
field,
|
|
3492
4207
|
direction
|
|
3493
|
-
}))
|
|
4208
|
+
}));
|
|
4209
|
+
}
|
|
4210
|
+
/**
|
|
4211
|
+
* Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
|
|
4212
|
+
*
|
|
4213
|
+
* Lenient parsing:
|
|
4214
|
+
* - Bare field name (no colon): `"name"` → `["name", "asc"]`
|
|
4215
|
+
* - Unknown direction: `"name:foo"` → `["name", "asc"]`
|
|
4216
|
+
* - Empty / falsy input, or a blank field name: → `undefined`
|
|
4217
|
+
*
|
|
4218
|
+
* The leniency is this end's alone; the *server* refuses the same value. This
|
|
4219
|
+
* used to say "matches existing server behaviour", and it stopped being true
|
|
4220
|
+
* when `parseOrderByParam` grew a strict direction check: `?orderBy=name:foo`
|
|
4221
|
+
* now answers `400 INVALID_ORDER_BY` ("entry 0 has direction 'foo'"). The split
|
|
4222
|
+
* is deliberate — see {@link parseOrderBySpecStrict} — because a value this
|
|
4223
|
+
* function is handed was produced by {@link serializeOrderBy} a moment earlier,
|
|
4224
|
+
* and one that reaches the server came from a stranger.
|
|
4225
|
+
*
|
|
4226
|
+
* A blank field is `undefined` rather than `[" ", "asc"]`: whitespace is not a
|
|
4227
|
+
* field name, and the tuple it used to produce could not be re-encoded — the
|
|
4228
|
+
* only value in this codec that survived a decode and failed the next encode.
|
|
4229
|
+
*
|
|
4230
|
+
* Reads the single-key shorthand only. For a value that may carry several keys,
|
|
4231
|
+
* use {@link deserializeOrderByList} — handed a JSON array this returns the
|
|
4232
|
+
* whole array as one nonsensical field name.
|
|
4233
|
+
*
|
|
4234
|
+
* @param raw - The wire-format string from an HTTP query parameter.
|
|
4235
|
+
* @returns The canonical tuple, or `undefined` if the input names no field.
|
|
4236
|
+
*/
|
|
4237
|
+
function deserializeOrderBy(raw) {
|
|
4238
|
+
if (!raw) return void 0;
|
|
4239
|
+
const idx = raw.indexOf(":");
|
|
4240
|
+
if (idx === -1) return raw.trim() === "" ? void 0 : [raw, "asc"];
|
|
4241
|
+
const field = raw.slice(0, idx);
|
|
4242
|
+
if (field.trim() === "") return void 0;
|
|
4243
|
+
const rest = raw.slice(idx + 1);
|
|
4244
|
+
const nullsIdx = rest.indexOf(":");
|
|
4245
|
+
const dir = nullsIdx === -1 ? rest : rest.slice(0, nullsIdx);
|
|
4246
|
+
const nulls = nullsIdx === -1 ? void 0 : rest.slice(nullsIdx + 1);
|
|
4247
|
+
const direction = dir === "desc" ? "desc" : "asc";
|
|
4248
|
+
return nulls === "first" || nulls === "last" ? [
|
|
4249
|
+
field,
|
|
4250
|
+
direction,
|
|
4251
|
+
nulls
|
|
4252
|
+
] : [field, direction];
|
|
4253
|
+
}
|
|
4254
|
+
/**
|
|
4255
|
+
* Deserialize either wire spelling — the single-key shorthand or the JSON
|
|
4256
|
+
* array — into the list form.
|
|
4257
|
+
*
|
|
4258
|
+
* Lenient in the same way {@link deserializeOrderBy} is: this is the client end
|
|
4259
|
+
* of the codec, where the value was produced by {@link serializeOrderBy} a
|
|
4260
|
+
* moment earlier. The *server* end parses the same shapes strictly, in
|
|
4261
|
+
* `parseOrderByParam`, because there the value came from a stranger and a
|
|
4262
|
+
* direction it cannot read has to be refused rather than quietly turned into
|
|
4263
|
+
* `"asc"`.
|
|
4264
|
+
*/
|
|
4265
|
+
function deserializeOrderByList(raw) {
|
|
4266
|
+
if (!raw) return void 0;
|
|
4267
|
+
const trimmed = raw.trim();
|
|
4268
|
+
if (trimmed.startsWith("[")) try {
|
|
4269
|
+
const parsed = JSON.parse(trimmed);
|
|
4270
|
+
if (Array.isArray(parsed)) {
|
|
4271
|
+
const list = parsed.map((entry) => {
|
|
4272
|
+
if (typeof entry === "string") return deserializeOrderBy(entry);
|
|
4273
|
+
if (entry && typeof entry === "object" && typeof entry.field === "string") {
|
|
4274
|
+
const direction = entry.direction === "desc" ? "desc" : "asc";
|
|
4275
|
+
return entry.nulls === "first" || entry.nulls === "last" ? [
|
|
4276
|
+
entry.field,
|
|
4277
|
+
direction,
|
|
4278
|
+
entry.nulls
|
|
4279
|
+
] : [entry.field, direction];
|
|
4280
|
+
}
|
|
4281
|
+
}).filter((entry) => entry !== void 0);
|
|
4282
|
+
return list.length > 0 ? list : void 0;
|
|
4283
|
+
}
|
|
4284
|
+
} catch {}
|
|
4285
|
+
const single = deserializeOrderBy(trimmed);
|
|
4286
|
+
return single ? [single] : void 0;
|
|
4287
|
+
}
|
|
4288
|
+
//#endregion
|
|
4289
|
+
//#region ../common/src/data/include-spec.ts
|
|
4290
|
+
/** An `include` that cannot be read, as opposed to one naming a relation that does not exist. */
|
|
4291
|
+
var IncludeSpecError = class IncludeSpecError extends Error {
|
|
4292
|
+
code;
|
|
4293
|
+
constructor(detail, code = "INVALID_INCLUDE") {
|
|
4294
|
+
super(`Invalid \`include\`: ${detail}`);
|
|
4295
|
+
this.name = "IncludeSpecError";
|
|
4296
|
+
this.code = code;
|
|
4297
|
+
Object.setPrototypeOf(this, IncludeSpecError.prototype);
|
|
4298
|
+
}
|
|
4299
|
+
};
|
|
4300
|
+
var emptyNode = () => ({ children: {} });
|
|
4301
|
+
function ensureNode(tree, key) {
|
|
4302
|
+
return tree[key] ??= emptyNode();
|
|
4303
|
+
}
|
|
4304
|
+
/**
|
|
4305
|
+
* Merge one dotted path (`"comments.author"`) into a tree.
|
|
4306
|
+
*
|
|
4307
|
+
* Merging rather than assigning is what makes `include=comments,comments.author`
|
|
4308
|
+
* mean the same thing as `include=comments.author`: the second path deepens the
|
|
4309
|
+
* node the first created instead of replacing it and losing its options.
|
|
4310
|
+
*/
|
|
4311
|
+
function addPath(tree, path) {
|
|
4312
|
+
const segments = path.split(".").map((s) => s.trim()).filter(Boolean);
|
|
4313
|
+
if (segments.length === 0) return;
|
|
4314
|
+
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");
|
|
4315
|
+
let level = tree;
|
|
4316
|
+
for (const segment of segments) level = ensureNode(level, segment).children;
|
|
4317
|
+
}
|
|
4318
|
+
function normalizeOptions(key, options, depth) {
|
|
4319
|
+
if (depth > 3) throw new IncludeSpecError(`"${key}" nests more than 3 relations deep.`, "INCLUDE_TOO_DEEP");
|
|
4320
|
+
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.`);
|
|
4321
|
+
const node = { children: {} };
|
|
4322
|
+
if (options.limit !== void 0) node.limit = options.limit;
|
|
4323
|
+
if (options.where) node.where = options.where;
|
|
4324
|
+
if (options.logical) node.logical = options.logical;
|
|
4325
|
+
if (options.fields && options.fields.length > 0) node.fields = [...options.fields];
|
|
4326
|
+
const orderBy = typeof options.orderBy === "string" ? deserializeOrderByList(options.orderBy) : normalizeOrderBy(options.orderBy);
|
|
4327
|
+
if (orderBy) node.orderBy = orderBy;
|
|
4328
|
+
if (options.include) {
|
|
4329
|
+
const nested = normalizeIncludeAt(options.include, depth + 1);
|
|
4330
|
+
if (nested.wildcard) throw new IncludeSpecError(`"${key}" asks for \`*\` inside a nested include. Name the relations you need.`);
|
|
4331
|
+
node.children = nested.tree;
|
|
4332
|
+
}
|
|
4333
|
+
return node;
|
|
4334
|
+
}
|
|
4335
|
+
function normalizeIncludeAt(spec, depth) {
|
|
4336
|
+
if (Array.isArray(spec)) {
|
|
4337
|
+
const tree = {};
|
|
4338
|
+
let wildcard = false;
|
|
4339
|
+
for (const raw of spec) {
|
|
4340
|
+
if (typeof raw !== "string") throw new IncludeSpecError(`${typeof raw} is not a relation name`);
|
|
4341
|
+
const name = raw.trim();
|
|
4342
|
+
if (!name) continue;
|
|
4343
|
+
if (name === "*") {
|
|
4344
|
+
wildcard = true;
|
|
4345
|
+
continue;
|
|
4346
|
+
}
|
|
4347
|
+
addPath(tree, name);
|
|
4348
|
+
}
|
|
4349
|
+
return {
|
|
4350
|
+
wildcard,
|
|
4351
|
+
tree
|
|
4352
|
+
};
|
|
4353
|
+
}
|
|
4354
|
+
if (typeof spec !== "object" || spec === null) throw new IncludeSpecError(`${typeof spec} is not a list of relations or an include tree`);
|
|
4355
|
+
const tree = {};
|
|
4356
|
+
let wildcard = false;
|
|
4357
|
+
for (const [key, value] of Object.entries(spec)) {
|
|
4358
|
+
if (key === "*") {
|
|
4359
|
+
if (value) wildcard = true;
|
|
4360
|
+
continue;
|
|
4361
|
+
}
|
|
4362
|
+
if (value === true) {
|
|
4363
|
+
ensureNode(tree, key);
|
|
4364
|
+
continue;
|
|
4365
|
+
}
|
|
4366
|
+
if (value === false || value === void 0 || value === null) continue;
|
|
4367
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new IncludeSpecError(`"${key}" must be \`true\` or an options object`);
|
|
4368
|
+
tree[key] = normalizeOptions(key, value, depth);
|
|
4369
|
+
}
|
|
4370
|
+
return {
|
|
4371
|
+
wildcard,
|
|
4372
|
+
tree
|
|
4373
|
+
};
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Collapse any {@link IncludeSpec} spelling into one tree.
|
|
4377
|
+
*
|
|
4378
|
+
* `["author", "comments.author"]` and
|
|
4379
|
+
* `{ author: true, comments: { include: { author: true } } }` normalize to the
|
|
4380
|
+
* same value — which is the whole point: the REST parameter can only carry the
|
|
4381
|
+
* flat spelling, the SDK prefers the tree, and the driver should never learn
|
|
4382
|
+
* about either.
|
|
4383
|
+
*
|
|
4384
|
+
* @throws {IncludeSpecError} for a shape that is not an include at all, or one
|
|
4385
|
+
* that nests past {@link MAX_INCLUDE_DEPTH}.
|
|
4386
|
+
*/
|
|
4387
|
+
function normalizeInclude(spec) {
|
|
4388
|
+
if (spec === void 0 || spec === null) return void 0;
|
|
4389
|
+
const normalized = normalizeIncludeAt(spec, 1);
|
|
4390
|
+
if (!normalized.wildcard && Object.keys(normalized.tree).length === 0) return void 0;
|
|
4391
|
+
return normalized;
|
|
4392
|
+
}
|
|
4393
|
+
/**
|
|
4394
|
+
* Every relation name a tree names, as dotted paths — `["comments",
|
|
4395
|
+
* "comments.author"]`.
|
|
4396
|
+
*
|
|
4397
|
+
* Used to report which names an `include` asked for when one of them is not a
|
|
4398
|
+
* relation, and to serialize a tree that carries no per-relation options back
|
|
4399
|
+
* to the flat wire spelling.
|
|
4400
|
+
*/
|
|
4401
|
+
function includePaths(tree, prefix = "") {
|
|
4402
|
+
const out = [];
|
|
4403
|
+
for (const [key, node] of Object.entries(tree)) {
|
|
4404
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
4405
|
+
out.push(path);
|
|
4406
|
+
out.push(...includePaths(node.children, path));
|
|
4407
|
+
}
|
|
4408
|
+
return out;
|
|
4409
|
+
}
|
|
4410
|
+
/**
|
|
4411
|
+
* The relation names an `include` asks for at the top level.
|
|
4412
|
+
*
|
|
4413
|
+
* `["author", "comments.author"]` and `{author: true, comments: {...}}` both
|
|
4414
|
+
* answer `["author", "comments"]` — a *hop*, not a path, because the only
|
|
4415
|
+
* consumer is `?fields=`, which names keys on the row being returned and a
|
|
4416
|
+
* nested relation is not one of those.
|
|
4417
|
+
*
|
|
4418
|
+
* Derived rather than passed: `include` has four spellings and three of them
|
|
4419
|
+
* are not a `string[]`, so every consumer that wants the plain names either
|
|
4420
|
+
* calls this or reimplements the flattening.
|
|
4421
|
+
*/
|
|
4422
|
+
function topLevelIncludeNames(spec) {
|
|
4423
|
+
const normalized = normalizeInclude(spec);
|
|
4424
|
+
if (!normalized) return [];
|
|
4425
|
+
return Object.keys(normalized.tree);
|
|
4426
|
+
}
|
|
4427
|
+
/** Whether any node in the tree carries per-relation options. */
|
|
4428
|
+
function hasOptions(tree) {
|
|
4429
|
+
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 || hasOptions(node.children));
|
|
4430
|
+
}
|
|
4431
|
+
/**
|
|
4432
|
+
* Serialize an {@link IncludeSpec} for the REST `?include=` parameter.
|
|
4433
|
+
*
|
|
4434
|
+
* Two spellings, and which one is used is decided by the request rather than
|
|
4435
|
+
* chosen:
|
|
4436
|
+
*
|
|
4437
|
+
* - **Comma-separated dotted paths** — `include=author,comments.author`. What a
|
|
4438
|
+
* plain include is, what a human types, and what every existing client sends.
|
|
4439
|
+
* - **JSON**, when any relation carries options — `include={"comments":{"limit":5,
|
|
4440
|
+
* "include":{"author":true}}}`. The flat spelling has nowhere to put a
|
|
4441
|
+
* `limit`, and inventing a punctuation for it (`comments(limit:5)`) would be a
|
|
4442
|
+
* third grammar to learn beside the two this API already has.
|
|
4443
|
+
*
|
|
4444
|
+
* The server accepts both on every list and get route, and tells them apart the
|
|
4445
|
+
* same way this does: a value starting with `{` is JSON.
|
|
4446
|
+
*/
|
|
4447
|
+
function serializeInclude(spec) {
|
|
4448
|
+
const normalized = normalizeInclude(spec);
|
|
4449
|
+
if (!normalized) return void 0;
|
|
4450
|
+
if (normalized.wildcard && Object.keys(normalized.tree).length === 0) return "*";
|
|
4451
|
+
if (!hasOptions(normalized.tree)) {
|
|
4452
|
+
const paths = includePaths(normalized.tree);
|
|
4453
|
+
const leaves = paths.filter((path) => !paths.some((other) => other.startsWith(`${path}.`)));
|
|
4454
|
+
const all = normalized.wildcard ? ["*", ...leaves] : leaves;
|
|
4455
|
+
return all.length > 0 ? all.join(",") : void 0;
|
|
4456
|
+
}
|
|
4457
|
+
return JSON.stringify(toWireTree(normalized));
|
|
4458
|
+
}
|
|
4459
|
+
/**
|
|
4460
|
+
* A normalized tree, back in the {@link IncludeSpec} spelling a caller writes.
|
|
4461
|
+
*
|
|
4462
|
+
* The round trip is what lets a builder accumulate `include` calls: normalize
|
|
4463
|
+
* each, merge, and hand the result back as a spec the next layer can normalize
|
|
4464
|
+
* again. Idempotent, so doing it twice changes nothing.
|
|
4465
|
+
*/
|
|
4466
|
+
function denormalizeInclude(normalized) {
|
|
4467
|
+
return toWireTree(normalized);
|
|
4468
|
+
}
|
|
4469
|
+
function mergeTrees(into, from) {
|
|
4470
|
+
for (const [key, node] of Object.entries(from)) {
|
|
4471
|
+
const existing = into[key];
|
|
4472
|
+
if (!existing) {
|
|
4473
|
+
into[key] = node;
|
|
4474
|
+
continue;
|
|
4475
|
+
}
|
|
4476
|
+
if (node.limit !== void 0) existing.limit = node.limit;
|
|
4477
|
+
if (node.where !== void 0) existing.where = node.where;
|
|
4478
|
+
if (node.logical !== void 0) existing.logical = node.logical;
|
|
4479
|
+
if (node.orderBy !== void 0) existing.orderBy = node.orderBy;
|
|
4480
|
+
if (node.fields !== void 0) existing.fields = node.fields;
|
|
4481
|
+
existing.children = mergeTrees(existing.children, node.children);
|
|
4482
|
+
}
|
|
4483
|
+
return into;
|
|
4484
|
+
}
|
|
4485
|
+
/**
|
|
4486
|
+
* Combine several `include` requests into one.
|
|
4487
|
+
*
|
|
4488
|
+
* Repeated `.include(...)` calls on a query builder are additive: each names
|
|
4489
|
+
* more of the graph to load, and a later one must not discard what an earlier
|
|
4490
|
+
* one asked for. Assigning instead of merging is why `.include("author")
|
|
4491
|
+
* .include("tags")` used to load only tags.
|
|
4492
|
+
*/
|
|
4493
|
+
function mergeIncludeSpecs(existing, additions) {
|
|
4494
|
+
const merged = {
|
|
4495
|
+
wildcard: false,
|
|
4496
|
+
tree: {}
|
|
4497
|
+
};
|
|
4498
|
+
const absorb = (spec) => {
|
|
4499
|
+
const normalized = normalizeInclude(spec);
|
|
4500
|
+
if (!normalized) return;
|
|
4501
|
+
merged.wildcard ||= normalized.wildcard;
|
|
4502
|
+
mergeTrees(merged.tree, normalized.tree);
|
|
4503
|
+
};
|
|
4504
|
+
absorb(existing);
|
|
4505
|
+
const names = additions.filter((a) => typeof a === "string");
|
|
4506
|
+
if (names.length > 0) absorb(names);
|
|
4507
|
+
for (const addition of additions) if (typeof addition !== "string") absorb(addition);
|
|
4508
|
+
if (!merged.wildcard && Object.keys(merged.tree).length === 0) return void 0;
|
|
4509
|
+
return denormalizeInclude(merged);
|
|
4510
|
+
}
|
|
4511
|
+
function toWireTree(normalized) {
|
|
4512
|
+
const emit = (tree) => {
|
|
4513
|
+
const out = {};
|
|
4514
|
+
for (const [key, node] of Object.entries(tree)) {
|
|
4515
|
+
const options = {};
|
|
4516
|
+
if (node.limit !== void 0) options.limit = node.limit;
|
|
4517
|
+
if (node.where) options.where = node.where;
|
|
4518
|
+
if (node.logical) options.logical = node.logical;
|
|
4519
|
+
if (node.orderBy) options.orderBy = node.orderBy;
|
|
4520
|
+
if (node.fields) options.fields = node.fields;
|
|
4521
|
+
const children = emit(node.children);
|
|
4522
|
+
if (Object.keys(children).length > 0) options.include = children;
|
|
4523
|
+
out[key] = Object.keys(options).length > 0 ? options : true;
|
|
4524
|
+
}
|
|
4525
|
+
return out;
|
|
4526
|
+
};
|
|
4527
|
+
const tree = emit(normalized.tree);
|
|
4528
|
+
if (normalized.wildcard) tree["*"] = true;
|
|
4529
|
+
return tree;
|
|
4530
|
+
}
|
|
4531
|
+
/**
|
|
4532
|
+
* Read the REST `?include=` parameter, in either spelling.
|
|
4533
|
+
*
|
|
4534
|
+
* @throws {IncludeSpecError} for malformed JSON or a tree that nests too deep.
|
|
4535
|
+
*/
|
|
4536
|
+
function deserializeInclude(raw) {
|
|
4537
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
4538
|
+
const text = raw.trim();
|
|
4539
|
+
if (!text) return void 0;
|
|
4540
|
+
if (text.startsWith("{")) {
|
|
4541
|
+
let parsed;
|
|
4542
|
+
try {
|
|
4543
|
+
parsed = JSON.parse(text);
|
|
4544
|
+
} catch {
|
|
4545
|
+
throw new IncludeSpecError("the parametrised form must be a JSON object, e.g. {\"comments\":{\"limit\":5,\"include\":{\"author\":true}}}");
|
|
4546
|
+
}
|
|
4547
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new IncludeSpecError("the parametrised form must be a JSON object");
|
|
4548
|
+
return parsed;
|
|
4549
|
+
}
|
|
4550
|
+
return text.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3494
4551
|
}
|
|
3495
4552
|
//#endregion
|
|
3496
4553
|
//#region ../common/src/data/query_builder.ts
|
|
@@ -3671,26 +4728,6 @@ function normalizeMaxRows(raw) {
|
|
|
3671
4728
|
return Math.max(0, Math.floor(raw));
|
|
3672
4729
|
}
|
|
3673
4730
|
/**
|
|
3674
|
-
* Add one condition to a `where` map without disturbing what is already there.
|
|
3675
|
-
*
|
|
3676
|
-
* The caller's own filter on the cursor column has to survive — dropping it
|
|
3677
|
-
* would widen the query, which is the silent-filter-loss failure mode — so a
|
|
3678
|
-
* second condition on the same column becomes the array-of-tuples form that
|
|
3679
|
-
* `FindParams.where` already accepts, and both are AND-ed.
|
|
3680
|
-
*/
|
|
3681
|
-
function appendCondition(where, column, condition) {
|
|
3682
|
-
const next = { ...where ?? {} };
|
|
3683
|
-
const existing = next[column];
|
|
3684
|
-
if (existing === void 0) next[column] = condition;
|
|
3685
|
-
else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) next[column] = [...existing, condition];
|
|
3686
|
-
else next[column] = [existing, condition];
|
|
3687
|
-
return next;
|
|
3688
|
-
}
|
|
3689
|
-
function cursorEquals(a, b) {
|
|
3690
|
-
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
3691
|
-
return Object.is(a, b);
|
|
3692
|
-
}
|
|
3693
|
-
/**
|
|
3694
4731
|
* Walk every row a query matches, yielding one row at a time and fetching the
|
|
3695
4732
|
* next page only when the consumer asks for it.
|
|
3696
4733
|
*
|
|
@@ -3706,30 +4743,23 @@ async function* paginateFind(find, params, label = "collection") {
|
|
|
3706
4743
|
const findParams = { ...rest };
|
|
3707
4744
|
const size = normalizePageSize(pageSize);
|
|
3708
4745
|
const pageCap = normalizeMaxPages(maxPages);
|
|
3709
|
-
const
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
if (orderBy && orderBy.length > 1) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by ${orderBy.map(([field]) => `"${field}"`).join(", ")}: keyset pagination advances along a single column. Order by "${cursorField}" alone, or drop the cursor and page by offset.`);
|
|
3715
|
-
if (orderBy && orderBy[0][0] !== cursorField) throw new RebasePaginationError("cursor-order-mismatch", `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0][0]}": keyset pagination only advances along the column the query is sorted by. Order by "${cursorField}", or drop the cursor and page by offset.`);
|
|
3716
|
-
direction = requestedDirection ?? orderBy?.[0][1] ?? "asc";
|
|
3717
|
-
findParams.orderBy = [cursorField, direction];
|
|
4746
|
+
const seekRequested = cursor !== void 0 && cursor !== null;
|
|
4747
|
+
if (seekRequested) {
|
|
4748
|
+
const field = typeof cursor === "string" ? cursor : cursor.field;
|
|
4749
|
+
const requested = typeof cursor === "object" && cursor !== null ? cursor.direction : void 0;
|
|
4750
|
+
if (!normalizeOrderBy(findParams.orderBy)) findParams.orderBy = [field, requested ?? "asc"];
|
|
3718
4751
|
}
|
|
3719
|
-
const seekOp = direction === "desc" ? "<" : ">";
|
|
3720
|
-
const baseWhere = findParams.where;
|
|
3721
4752
|
let offset = 0;
|
|
3722
4753
|
let pages = 0;
|
|
3723
|
-
let
|
|
3724
|
-
let seeking = false;
|
|
4754
|
+
let after;
|
|
3725
4755
|
for (;;) {
|
|
3726
4756
|
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\`.`);
|
|
3727
4757
|
const pageParams = {
|
|
3728
4758
|
...findParams,
|
|
3729
4759
|
limit: size
|
|
3730
4760
|
};
|
|
3731
|
-
if (
|
|
3732
|
-
if (
|
|
4761
|
+
if (seekRequested) {
|
|
4762
|
+
if (after) pageParams.after = after;
|
|
3733
4763
|
} else pageParams.offset = offset;
|
|
3734
4764
|
const page = await find(pageParams);
|
|
3735
4765
|
pages += 1;
|
|
@@ -3737,12 +4767,11 @@ async function* paginateFind(find, params, label = "collection") {
|
|
|
3737
4767
|
if (rows.length === 0) return;
|
|
3738
4768
|
for (const row of rows) yield row;
|
|
3739
4769
|
if (page?.meta?.hasMore !== true) return;
|
|
3740
|
-
if (
|
|
3741
|
-
const
|
|
3742
|
-
if (
|
|
3743
|
-
if (
|
|
3744
|
-
|
|
3745
|
-
seeking = true;
|
|
4770
|
+
if (seekRequested) {
|
|
4771
|
+
const next = page.meta.nextCursor;
|
|
4772
|
+
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.`);
|
|
4773
|
+
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.`);
|
|
4774
|
+
after = next;
|
|
3746
4775
|
} else offset += rows.length;
|
|
3747
4776
|
}
|
|
3748
4777
|
}
|
|
@@ -4172,15 +5201,66 @@ function serializeFilter(filter) {
|
|
|
4172
5201
|
return result;
|
|
4173
5202
|
}
|
|
4174
5203
|
/**
|
|
5204
|
+
* The spellings a null-testing operator's operand may take.
|
|
5205
|
+
*
|
|
5206
|
+
* The serializer writes `isnull.null`; a hand-written `isnull.true` means the
|
|
5207
|
+
* same thing and has always been accepted. Anything else after the operator is
|
|
5208
|
+
* not an operand it has — `notnull.reason` is a *value* — see
|
|
5209
|
+
* {@link deserializeSingle}.
|
|
5210
|
+
*/
|
|
5211
|
+
var NULL_OPERANDS = /* @__PURE__ */ new Set([
|
|
5212
|
+
"null",
|
|
5213
|
+
"true",
|
|
5214
|
+
"false",
|
|
5215
|
+
""
|
|
5216
|
+
]);
|
|
5217
|
+
/**
|
|
4175
5218
|
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
4176
5219
|
*
|
|
4177
5220
|
* All values are returned as strings — the wire format carries no type
|
|
4178
5221
|
* metadata, so coercion is the data driver's responsibility.
|
|
4179
5222
|
*
|
|
4180
|
-
*
|
|
4181
|
-
*
|
|
4182
|
-
*
|
|
4183
|
-
*
|
|
5223
|
+
* ## When a leading segment is an operator, and when it is part of the value
|
|
5224
|
+
*
|
|
5225
|
+
* `?status=in.progress` and `?status=in.(a,b)` differ by one character and mean
|
|
5226
|
+
* entirely different things, and the reading here decides which. The rule, in
|
|
5227
|
+
* full:
|
|
5228
|
+
*
|
|
5229
|
+
* > A dot-string is read as `operator.operand` **only** when its first segment
|
|
5230
|
+
* > names a known REST operator **and** what follows is a well-formed operand
|
|
5231
|
+
* > *for that operator's arity*. Otherwise the whole string is the value.
|
|
5232
|
+
*
|
|
5233
|
+
* Arity, per operator family:
|
|
5234
|
+
*
|
|
5235
|
+
* - **List** operators (`in`, `nin`, `csa` — `LIST_OPS`) take a parenthesised
|
|
5236
|
+
* list and nothing else. `in.(draft,review)` is the operator; `in.progress`
|
|
5237
|
+
* is the *value* `"in.progress"`, because there is no list there and so no
|
|
5238
|
+
* `in` filter that could have been written. That case used to compile to
|
|
5239
|
+
* `status IN ('progress')` — a filter the caller never wrote, quietly
|
|
5240
|
+
* matching the wrong rows and, on a status field, hiding every row they were
|
|
5241
|
+
* looking for.
|
|
5242
|
+
* - **Null** operators (`isnull`, `notnull` — {@link NULL_OPS}) take no
|
|
5243
|
+
* operand: only {@link NULL_OPERANDS}. `notnull.reason` is the value
|
|
5244
|
+
* `"notnull.reason"`, not "reason is not null".
|
|
5245
|
+
* - **Everything else** takes one scalar, and any remainder is one — including
|
|
5246
|
+
* the empty string, so `eq.` really is "equals the empty string".
|
|
5247
|
+
*
|
|
5248
|
+
* ### The one ambiguity that remains, and how to write past it
|
|
5249
|
+
*
|
|
5250
|
+
* A scalar operator's operand is unconstrained, so `?status=like.that` is a
|
|
5251
|
+
* `LIKE 'that'` and no rule at this layer can tell it from the literal value
|
|
5252
|
+
* `"like.that"` — both are well-formed encodings, and picking either by guess
|
|
5253
|
+
* would break the other. Two spellings say "value" unambiguously, and both
|
|
5254
|
+
* round-trip:
|
|
5255
|
+
*
|
|
5256
|
+
* - `?status=eq.like.that` — name the operator. The *first* segment is consumed
|
|
5257
|
+
* as the operator and everything after it is the value, dots and all. This is
|
|
5258
|
+
* what `serializeFilter` emits, which is why the SDK never meets the
|
|
5259
|
+
* ambiguity at all.
|
|
5260
|
+
* - `?where={"status":["==","like.that"]}` — the JSON dialect's tuple form.
|
|
5261
|
+
*
|
|
5262
|
+
* Values that merely *contain* dots (`user@host.com`, `1.2.3`) were never
|
|
5263
|
+
* ambiguous: their first segment names no operator to begin with.
|
|
4184
5264
|
*/
|
|
4185
5265
|
function deserializeSingle(raw) {
|
|
4186
5266
|
const dotIndex = raw.indexOf(".");
|
|
@@ -4189,11 +5269,15 @@ function deserializeSingle(raw) {
|
|
|
4189
5269
|
const rest = raw.substring(dotIndex + 1);
|
|
4190
5270
|
const canonicalOp = REST_OP_LOOKUP.get(prefix);
|
|
4191
5271
|
if (!canonicalOp) return ["==", raw];
|
|
4192
|
-
if (NULL_OPS.has(canonicalOp))
|
|
5272
|
+
if (NULL_OPS.has(canonicalOp)) {
|
|
5273
|
+
if (!NULL_OPERANDS.has(rest)) return ["==", raw];
|
|
5274
|
+
return [canonicalOp, null];
|
|
5275
|
+
}
|
|
4193
5276
|
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
4194
5277
|
const inner = rest.slice(1, -1);
|
|
4195
5278
|
return [canonicalOp, inner === EMPTY_LIST_TOKEN ? [] : splitListItems(inner)];
|
|
4196
5279
|
}
|
|
5280
|
+
if (LIST_OPS.has(canonicalOp)) return ["==", raw];
|
|
4197
5281
|
return [canonicalOp, rest];
|
|
4198
5282
|
}
|
|
4199
5283
|
/**
|
|
@@ -4321,7 +5405,7 @@ function splitLeafCondition(str) {
|
|
|
4321
5405
|
}
|
|
4322
5406
|
function deserializeLogicalCondition(str, nesting = 0) {
|
|
4323
5407
|
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)\`.`);
|
|
4324
|
-
const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
|
|
5408
|
+
const logicalMatch = str.match(/^(and|or|not)\((.+)\)$/);
|
|
4325
5409
|
if (logicalMatch) {
|
|
4326
5410
|
const type = logicalMatch[1];
|
|
4327
5411
|
const innerStr = logicalMatch[2];
|
|
@@ -4375,6 +5459,33 @@ function deserializeLogicalCondition(str, nesting = 0) {
|
|
|
4375
5459
|
var noRealtime = (slug) => `Realtime is not available for "${slug}": its data source does not support subscriptions.`;
|
|
4376
5460
|
/** What a client says when its data source cannot count. */
|
|
4377
5461
|
var noCount = (slug) => `Counting is not available for "${slug}": its data source does not support it.`;
|
|
5462
|
+
/**
|
|
5463
|
+
* Derive the response key an aggregate comes back under.
|
|
5464
|
+
*
|
|
5465
|
+
* `sum(total)` → `sum_total`, `count()` → `count`. Written once, here, because
|
|
5466
|
+
* the REST parser derives the same alias from `?select=sum(total)` and the two
|
|
5467
|
+
* have to agree — a caller reading `row.sum_total` off an SDK result and off an
|
|
5468
|
+
* HTTP response is reading the same key or the SDK is broken.
|
|
5469
|
+
*/
|
|
5470
|
+
function aggregateAlias(fn, field) {
|
|
5471
|
+
return field ? `${fn}_${field}` : fn;
|
|
5472
|
+
}
|
|
5473
|
+
function toDriverAggregate(select) {
|
|
5474
|
+
const field = select.field;
|
|
5475
|
+
return {
|
|
5476
|
+
fn: select.fn,
|
|
5477
|
+
field,
|
|
5478
|
+
alias: aggregateAlias(select.fn, field)
|
|
5479
|
+
};
|
|
5480
|
+
}
|
|
5481
|
+
/**
|
|
5482
|
+
* What a client says when its data source cannot aggregate.
|
|
5483
|
+
*
|
|
5484
|
+
* A stub rather than a fallback that fetches and reduces in JavaScript: that
|
|
5485
|
+
* would be wrong under a `limit` and unaffordable without one, and it would look
|
|
5486
|
+
* like it had worked.
|
|
5487
|
+
*/
|
|
5488
|
+
var noAggregate = (slug) => `Aggregates are not available for "${slug}": its data source does not implement them.`;
|
|
4378
5489
|
function createPrimaryKeyResolver(options) {
|
|
4379
5490
|
const cache = /* @__PURE__ */ new Map();
|
|
4380
5491
|
const warned = /* @__PURE__ */ new Set();
|
|
@@ -4396,6 +5507,89 @@ function createPrimaryKeyResolver(options) {
|
|
|
4396
5507
|
};
|
|
4397
5508
|
}
|
|
4398
5509
|
/**
|
|
5510
|
+
* Build the admin's view model out of the row the wire serves.
|
|
5511
|
+
*
|
|
5512
|
+
* The wire has ONE shape, for every consumer: flat columns, typed the way the
|
|
5513
|
+
* database typed them, and a relation rendered as the target's own columns (or
|
|
5514
|
+
* only its foreign key, when nothing asked for it). That is the REST contract,
|
|
5515
|
+
* what `find()` returns, what `listen()` pushes, and what the generated types
|
|
5516
|
+
* describe.
|
|
5517
|
+
*
|
|
5518
|
+
* The admin renders neither of those directly. Its date field requires a real
|
|
5519
|
+
* `Date` and rejects a string outright; its relation cells read `.data.values`
|
|
5520
|
+
* off a relation ref. Those requirements are the *admin's*, so they are met
|
|
5521
|
+
* here — in the browser, from the collection config the panel already has —
|
|
5522
|
+
* rather than by asking the server for a second wire shape.
|
|
5523
|
+
*
|
|
5524
|
+
* That second shape is what this replaces. Until 2026-09-09 the realtime wire
|
|
5525
|
+
* carried the view model and every other read carried flat rows, so `find()`
|
|
5526
|
+
* and `listen()` answered one query two ways; unifying the wire without doing
|
|
5527
|
+
* this conversion is what left every date cell reading "Invalid date value"
|
|
5528
|
+
* and every relation cell "Unexpected value".
|
|
5529
|
+
*
|
|
5530
|
+
* Values already in view-model form pass through untouched: a driver that
|
|
5531
|
+
* still sends `{ __type: "date" }` or a relation ref (the client revives both)
|
|
5532
|
+
* is served by the same walk.
|
|
5533
|
+
*/
|
|
5534
|
+
function toViewModelValues(values, properties, collection, resolveCollection) {
|
|
5535
|
+
if (!properties) return values;
|
|
5536
|
+
const relations = collection ? resolveCollectionRelations(collection) : {};
|
|
5537
|
+
let out;
|
|
5538
|
+
const write = (key, value) => {
|
|
5539
|
+
out = out ?? { ...values };
|
|
5540
|
+
out[key] = value;
|
|
5541
|
+
};
|
|
5542
|
+
for (const [key, rawProperty] of Object.entries(properties)) {
|
|
5543
|
+
const property = rawProperty;
|
|
5544
|
+
if (!property) continue;
|
|
5545
|
+
if (!(key in values)) {
|
|
5546
|
+
const fkRelation = relations[key];
|
|
5547
|
+
const column = fkRelation && "localKey" in fkRelation ? fkRelation.localKey : void 0;
|
|
5548
|
+
const fk = column !== void 0 ? values[column] ?? values[toWireKey(column)] : void 0;
|
|
5549
|
+
const fkTarget = fkRelation?.targetSlug;
|
|
5550
|
+
if (fkTarget && (typeof fk === "string" || typeof fk === "number")) write(key, new EntityRelation(fk, fkTarget));
|
|
5551
|
+
continue;
|
|
5552
|
+
}
|
|
5553
|
+
const value = values[key];
|
|
5554
|
+
if (value === null || value === void 0) continue;
|
|
5555
|
+
const relation = relations[key];
|
|
5556
|
+
if (relation && (property.type === "relation" || property.of?.type === "relation" || property.type === "array")) {
|
|
5557
|
+
const target = relation.targetSlug;
|
|
5558
|
+
if (!target) continue;
|
|
5559
|
+
const targetProperties = resolveCollection?.(target)?.properties;
|
|
5560
|
+
const targetCollection = resolveCollection?.(target);
|
|
5561
|
+
const toRef = (item) => {
|
|
5562
|
+
if (item instanceof EntityRelation) return item;
|
|
5563
|
+
if (typeof item === "object" && item !== null && "__type" in item) return item;
|
|
5564
|
+
if (typeof item === "object" && item !== null) {
|
|
5565
|
+
const row = item;
|
|
5566
|
+
const keys = targetCollection ? resolvePrimaryKeys(targetCollection) : [];
|
|
5567
|
+
const id = keys.length > 0 ? buildCompositeId(row, keys) : row.id;
|
|
5568
|
+
if (id === void 0 || id === null || id === "") return item;
|
|
5569
|
+
return new EntityRelation(id, target, {
|
|
5570
|
+
id,
|
|
5571
|
+
path: target,
|
|
5572
|
+
values: toViewModelValues(row, targetProperties, targetCollection, resolveCollection)
|
|
5573
|
+
});
|
|
5574
|
+
}
|
|
5575
|
+
if (typeof item === "string" || typeof item === "number") return new EntityRelation(item, target);
|
|
5576
|
+
return item;
|
|
5577
|
+
};
|
|
5578
|
+
write(key, Array.isArray(value) ? value.map(toRef) : toRef(value));
|
|
5579
|
+
continue;
|
|
5580
|
+
}
|
|
5581
|
+
if (property.type === "date" && !(value instanceof Date)) {
|
|
5582
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
5583
|
+
const date = new Date(value);
|
|
5584
|
+
write(key, isNaN(date.getTime()) ? null : date);
|
|
5585
|
+
}
|
|
5586
|
+
continue;
|
|
5587
|
+
}
|
|
5588
|
+
if (property.type === "map" && property.properties && typeof value === "object" && !Array.isArray(value)) write(key, toViewModelValues(value, property.properties, void 0, resolveCollection));
|
|
5589
|
+
}
|
|
5590
|
+
return out ?? values;
|
|
5591
|
+
}
|
|
5592
|
+
/**
|
|
4399
5593
|
* Give a flat row the Entity view-model the admin renders.
|
|
4400
5594
|
*
|
|
4401
5595
|
* The address is *derived here* — it is not a column, and the row it came from
|
|
@@ -4406,12 +5600,12 @@ function createPrimaryKeyResolver(options) {
|
|
|
4406
5600
|
* `primaryKeys` empty falls back to a literal `id` on the row: drivers other
|
|
4407
5601
|
* than postgres still serve rows with one, and this keeps them working.
|
|
4408
5602
|
*/
|
|
4409
|
-
function rowToEntity(row, slug, primaryKeys = []) {
|
|
5603
|
+
function rowToEntity(row, slug, primaryKeys = [], toViewModel) {
|
|
4410
5604
|
const { _matches, ...values } = row;
|
|
4411
5605
|
return {
|
|
4412
5606
|
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
4413
5607
|
path: slug,
|
|
4414
|
-
values,
|
|
5608
|
+
values: toViewModel ? toViewModel(values) : values,
|
|
4415
5609
|
..._matches ? { searchMatches: _matches } : {}
|
|
4416
5610
|
};
|
|
4417
5611
|
}
|
|
@@ -4431,14 +5625,16 @@ function inlineEnvelope(envelope) {
|
|
|
4431
5625
|
* Replace every relation envelope on a row with the target's flat columns.
|
|
4432
5626
|
*
|
|
4433
5627
|
* The SDK serves one relation shape — the inlined one (see
|
|
4434
|
-
* {@link RestFetchService}) — and
|
|
4435
|
-
*
|
|
4436
|
-
*
|
|
4437
|
-
*
|
|
5628
|
+
* {@link RestFetchService}) — and Postgres now serves it on every read, so
|
|
5629
|
+
* against that driver this walk finds nothing to do. It stays for the drivers
|
|
5630
|
+
* whose own `fetchCollection` still answers with refs: a developer reading
|
|
5631
|
+
* through this accessor gets one shape whichever driver is underneath.
|
|
4438
5632
|
*
|
|
4439
5633
|
* Only applied where the REST pipeline is the contract (see `find`); a driver
|
|
4440
|
-
* without a `restFetchService` keeps whatever it returns
|
|
4441
|
-
*
|
|
5634
|
+
* without a `restFetchService` keeps whatever it returns.
|
|
5635
|
+
*
|
|
5636
|
+
* Note this is NOT how the admin gets its view model — that is built in the
|
|
5637
|
+
* browser by {@link toViewModelValues}, from the same flat row.
|
|
4442
5638
|
*/
|
|
4443
5639
|
function inlineRelationRefs(row) {
|
|
4444
5640
|
let out;
|
|
@@ -4451,30 +5647,43 @@ function inlineRelationRefs(row) {
|
|
|
4451
5647
|
}
|
|
4452
5648
|
return out ?? row;
|
|
4453
5649
|
}
|
|
4454
|
-
function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
5650
|
+
function createDriverAccessor(driver, slug, getPks = () => [], toViewModel) {
|
|
4455
5651
|
const accessor = {
|
|
4456
5652
|
async find(params) {
|
|
4457
5653
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
4458
5654
|
const { limit, offset, driverOffset } = resolveFindWindow(params);
|
|
5655
|
+
const cursor = params?.after ? decodeCursor(params.after) : void 0;
|
|
5656
|
+
const orderBy = cursor ? reconcileCursorOrder(cursor, normalizeOrderBy(params?.orderBy)) : normalizeOrderBy(params?.orderBy);
|
|
5657
|
+
const startAfter = cursor ? cursorToStartAfter(cursor) : void 0;
|
|
5658
|
+
const probeLimit = startAfter ? limit + 1 : limit;
|
|
4459
5659
|
const fetchService = driver.restFetchService;
|
|
4460
|
-
const
|
|
5660
|
+
const fetched = fetchService ? await fetchService.fetchCollectionForRest(slug, {
|
|
4461
5661
|
filter,
|
|
4462
5662
|
logical: params?.logical,
|
|
4463
|
-
limit,
|
|
4464
|
-
offset: driverOffset,
|
|
4465
|
-
|
|
4466
|
-
|
|
5663
|
+
limit: probeLimit,
|
|
5664
|
+
offset: startAfter ? void 0 : driverOffset,
|
|
5665
|
+
startAfter,
|
|
5666
|
+
orderBy,
|
|
5667
|
+
searchString: params?.searchString,
|
|
5668
|
+
fields: params?.fields,
|
|
5669
|
+
distinct: params?.distinct
|
|
4467
5670
|
}, params?.include) : await driver.fetchCollection({
|
|
4468
5671
|
path: slug,
|
|
4469
|
-
limit,
|
|
4470
|
-
offset: driverOffset,
|
|
5672
|
+
limit: probeLimit,
|
|
5673
|
+
offset: startAfter ? void 0 : driverOffset,
|
|
5674
|
+
startAfter,
|
|
4471
5675
|
filter,
|
|
4472
5676
|
logical: params?.logical,
|
|
4473
|
-
orderBy
|
|
4474
|
-
searchString: params?.searchString
|
|
5677
|
+
orderBy,
|
|
5678
|
+
searchString: params?.searchString,
|
|
5679
|
+
include: params?.include,
|
|
5680
|
+
fields: params?.fields,
|
|
5681
|
+
distinct: params?.distinct
|
|
4475
5682
|
});
|
|
5683
|
+
const seeking = startAfter !== void 0;
|
|
5684
|
+
const rows = seeking ? fetched.slice(0, limit) : fetched;
|
|
4476
5685
|
let total = rows.length + offset;
|
|
4477
|
-
let hasMore = rows.length >= limit;
|
|
5686
|
+
let hasMore = seeking ? fetched.length > limit : rows.length >= limit;
|
|
4478
5687
|
if (driver.count) {
|
|
4479
5688
|
total = await driver.count({
|
|
4480
5689
|
path: slug,
|
|
@@ -4482,15 +5691,18 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4482
5691
|
logical: params?.logical,
|
|
4483
5692
|
searchString: params?.searchString
|
|
4484
5693
|
});
|
|
4485
|
-
hasMore = offset + rows.length < total;
|
|
5694
|
+
if (!seeking) hasMore = offset + rows.length < total;
|
|
4486
5695
|
}
|
|
5696
|
+
const last = rows[rows.length - 1];
|
|
5697
|
+
const nextCursor = hasMore && last && driver.restFetchService?.cursorFor ? driver.restFetchService.cursorFor(slug, last, orderBy) : void 0;
|
|
4487
5698
|
return {
|
|
4488
|
-
data: rows.map((row) => rowToEntity(row, slug, getPks())),
|
|
5699
|
+
data: rows.map((row) => rowToEntity(row, slug, getPks(), toViewModel)),
|
|
4489
5700
|
meta: {
|
|
4490
5701
|
total,
|
|
4491
5702
|
limit,
|
|
4492
5703
|
offset,
|
|
4493
|
-
hasMore
|
|
5704
|
+
hasMore,
|
|
5705
|
+
...nextCursor && { nextCursor }
|
|
4494
5706
|
}
|
|
4495
5707
|
};
|
|
4496
5708
|
},
|
|
@@ -4500,22 +5712,31 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4500
5712
|
path: slug,
|
|
4501
5713
|
id
|
|
4502
5714
|
});
|
|
4503
|
-
return row ? rowToEntity(row, slug, getPks()) : void 0;
|
|
5715
|
+
return row ? rowToEntity(row, slug, getPks(), toViewModel) : void 0;
|
|
4504
5716
|
},
|
|
5717
|
+
aggregate: driver.restFetchService?.aggregate ? async (params) => driver.restFetchService.aggregate(slug, {
|
|
5718
|
+
aggregates: params.select.map(toDriverAggregate),
|
|
5719
|
+
groupBy: params.groupBy,
|
|
5720
|
+
filter: params.where ? deserializeFilter(params.where) : void 0,
|
|
5721
|
+
logical: params.logical,
|
|
5722
|
+
searchString: params.searchString,
|
|
5723
|
+
limit: params.limit
|
|
5724
|
+
}) : void 0,
|
|
4505
5725
|
async create(data, id) {
|
|
4506
5726
|
return rowToEntity(await driver.save({
|
|
4507
5727
|
path: slug,
|
|
4508
5728
|
values: data,
|
|
4509
5729
|
id,
|
|
4510
5730
|
status: "new"
|
|
4511
|
-
}), slug, getPks());
|
|
5731
|
+
}), slug, getPks(), toViewModel);
|
|
4512
5732
|
},
|
|
4513
5733
|
createMany: driver.saveMany ? async (data, options) => {
|
|
4514
5734
|
return (await driver.saveMany({
|
|
4515
5735
|
path: slug,
|
|
4516
5736
|
rows: data,
|
|
4517
|
-
upsert: options?.upsert
|
|
4518
|
-
|
|
5737
|
+
upsert: options?.upsert,
|
|
5738
|
+
onConflict: options?.onConflict
|
|
5739
|
+
})).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
|
|
4519
5740
|
} : void 0,
|
|
4520
5741
|
async update(id, data) {
|
|
4521
5742
|
return rowToEntity(await driver.save({
|
|
@@ -4523,7 +5744,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4523
5744
|
values: data,
|
|
4524
5745
|
id,
|
|
4525
5746
|
status: "existing"
|
|
4526
|
-
}), slug, getPks());
|
|
5747
|
+
}), slug, getPks(), toViewModel);
|
|
4527
5748
|
},
|
|
4528
5749
|
async delete(id) {
|
|
4529
5750
|
return driver.delete({ row: {
|
|
@@ -4539,7 +5760,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4539
5760
|
id: u.id,
|
|
4540
5761
|
values: u.data
|
|
4541
5762
|
}))
|
|
4542
|
-
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
5763
|
+
})).map((row) => rowToEntity(row, slug, getPks(), toViewModel));
|
|
4543
5764
|
} : void 0,
|
|
4544
5765
|
deleteMany: driver.deleteMany ? async (ids) => {
|
|
4545
5766
|
await driver.deleteMany({
|
|
@@ -4571,7 +5792,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4571
5792
|
vectorSearch: params?.vectorSearch,
|
|
4572
5793
|
onUpdate: (entities) => {
|
|
4573
5794
|
onUpdate({
|
|
4574
|
-
data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
|
|
5795
|
+
data: entities.map((row) => rowToEntity(normalize(row), slug, getPks(), toViewModel)),
|
|
4575
5796
|
meta: {
|
|
4576
5797
|
total: offset + entities.length,
|
|
4577
5798
|
limit,
|
|
@@ -4588,7 +5809,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4588
5809
|
return driver.listenOne({
|
|
4589
5810
|
path: slug,
|
|
4590
5811
|
id,
|
|
4591
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(normalize(entity), slug, getPks()) : void 0),
|
|
5812
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(normalize(entity), slug, getPks(), toViewModel) : void 0),
|
|
4592
5813
|
onError
|
|
4593
5814
|
});
|
|
4594
5815
|
} : void 0,
|
|
@@ -4630,13 +5851,32 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4630
5851
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
4631
5852
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
4632
5853
|
*/
|
|
5854
|
+
/**
|
|
5855
|
+
* The view-model converter for one collection, or `undefined` when there is no
|
|
5856
|
+
* collection config to build it from.
|
|
5857
|
+
*
|
|
5858
|
+
* Absent is the honest answer for every consumer that is not the admin: the
|
|
5859
|
+
* flat SDK derives itself from this same layer (`buildSdkData`) and must keep
|
|
5860
|
+
* the wire's own types, and it registers no collection resolver.
|
|
5861
|
+
*/
|
|
5862
|
+
function createViewModelConverter(options) {
|
|
5863
|
+
if (!options?.resolveCollection) return () => void 0;
|
|
5864
|
+
return function converterFor(slug) {
|
|
5865
|
+
return (values) => {
|
|
5866
|
+
const collection = options.resolveCollection?.(slug);
|
|
5867
|
+
if (!collection) return values;
|
|
5868
|
+
return toViewModelValues(values, collection.properties, collection, options.resolveCollection);
|
|
5869
|
+
};
|
|
5870
|
+
};
|
|
5871
|
+
}
|
|
4633
5872
|
function buildRebaseData(driver, options) {
|
|
4634
5873
|
const cache = /* @__PURE__ */ new Map();
|
|
4635
5874
|
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
5875
|
+
const viewModelFor = createViewModelConverter(options);
|
|
4636
5876
|
function getAccessor(slug) {
|
|
4637
5877
|
let accessor = cache.get(slug);
|
|
4638
5878
|
if (!accessor) {
|
|
4639
|
-
accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
|
|
5879
|
+
accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug), viewModelFor(slug));
|
|
4640
5880
|
cache.set(slug, accessor);
|
|
4641
5881
|
}
|
|
4642
5882
|
return accessor;
|
|
@@ -4691,9 +5931,14 @@ var SdkQueryBuilder = class {
|
|
|
4691
5931
|
return this;
|
|
4692
5932
|
}
|
|
4693
5933
|
/** Called again, this adds a tie-breaker rather than replacing the sort. */
|
|
4694
|
-
orderBy(column, direction = "asc") {
|
|
5934
|
+
orderBy(column, direction = "asc", nulls) {
|
|
4695
5935
|
const existing = normalizeOrderBy(this.params.orderBy) ?? [];
|
|
4696
|
-
|
|
5936
|
+
const key = sortKeyToString(column);
|
|
5937
|
+
this.params.orderBy = [...existing, nulls ? [
|
|
5938
|
+
key,
|
|
5939
|
+
direction,
|
|
5940
|
+
nulls
|
|
5941
|
+
] : [key, direction]];
|
|
4697
5942
|
return this;
|
|
4698
5943
|
}
|
|
4699
5944
|
limit(count) {
|
|
@@ -4718,13 +5963,39 @@ var SdkQueryBuilder = class {
|
|
|
4718
5963
|
};
|
|
4719
5964
|
return this;
|
|
4720
5965
|
}
|
|
5966
|
+
/**
|
|
5967
|
+
* Load relations. Merges rather than replaces, so `.include("author")` then
|
|
5968
|
+
* `.include({ comments: { limit: 5 } })` asks for both — a builder call that
|
|
5969
|
+
* silently discarded an earlier one is the same defect `where` had.
|
|
5970
|
+
*/
|
|
4721
5971
|
include(...relations) {
|
|
4722
|
-
this.params.include = relations;
|
|
5972
|
+
this.params.include = mergeIncludeSpecs(this.params.include, relations);
|
|
5973
|
+
return this;
|
|
5974
|
+
}
|
|
5975
|
+
fields(...columns) {
|
|
5976
|
+
this.params.fields = [...this.params.fields ?? [], ...columns];
|
|
5977
|
+
return this;
|
|
5978
|
+
}
|
|
5979
|
+
distinct(enabled = true) {
|
|
5980
|
+
this.params.distinct = enabled;
|
|
5981
|
+
return this;
|
|
5982
|
+
}
|
|
5983
|
+
after(cursor) {
|
|
5984
|
+
this.params.after = cursor;
|
|
4723
5985
|
return this;
|
|
4724
5986
|
}
|
|
4725
5987
|
async find() {
|
|
4726
5988
|
return this.client.find(this.params);
|
|
4727
5989
|
}
|
|
5990
|
+
/** Aggregate the matching rows. See {@link SDKCollectionClient.aggregate}. */
|
|
5991
|
+
async aggregate(params) {
|
|
5992
|
+
return this.client.aggregate({
|
|
5993
|
+
...params,
|
|
5994
|
+
where: this.params.where,
|
|
5995
|
+
logical: this.params.logical,
|
|
5996
|
+
searchString: this.params.searchString
|
|
5997
|
+
});
|
|
5998
|
+
}
|
|
4728
5999
|
/**
|
|
4729
6000
|
* Page through everything this query matches, one row at a time.
|
|
4730
6001
|
*
|
|
@@ -4802,6 +6073,24 @@ function toSdkCollectionClient(snap, slug = "collection") {
|
|
|
4802
6073
|
if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
|
|
4803
6074
|
return (await snap.createMany(data, options)).map(entityToRow);
|
|
4804
6075
|
},
|
|
6076
|
+
/**
|
|
6077
|
+
* One row through the bulk path, because the bulk path is where the
|
|
6078
|
+
* conflict target lives.
|
|
6079
|
+
*
|
|
6080
|
+
* `CollectionAccessor` has no single-row upsert and adding one would
|
|
6081
|
+
* mean a second way to say the same thing to the same driver method —
|
|
6082
|
+
* `saveMany` already takes `upsert` and `onConflict`, and a batch of
|
|
6083
|
+
* one is exactly an upsert of one.
|
|
6084
|
+
*/
|
|
6085
|
+
async upsert(data, options) {
|
|
6086
|
+
if (!snap.createMany) throw new Error("Upsert is not supported by this collection's data source: it needs a bulk write, which this driver does not implement. Fall back to create() or update().");
|
|
6087
|
+
const row = (await snap.createMany([data], {
|
|
6088
|
+
upsert: true,
|
|
6089
|
+
onConflict: options?.onConflict
|
|
6090
|
+
}))[0];
|
|
6091
|
+
if (!row) throw new Error(`Upsert into "${slug}" returned no row.`);
|
|
6092
|
+
return entityToRow(row);
|
|
6093
|
+
},
|
|
4805
6094
|
async update(id, data) {
|
|
4806
6095
|
return entityToRow(await snap.update(id, data));
|
|
4807
6096
|
},
|
|
@@ -4834,12 +6123,16 @@ function toSdkCollectionClient(snap, slug = "collection") {
|
|
|
4834
6123
|
if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
|
|
4835
6124
|
return builder.where(columnOrCondition, operator, value);
|
|
4836
6125
|
},
|
|
4837
|
-
orderBy: (column, direction) => new SdkQueryBuilder(client).orderBy(column, direction),
|
|
6126
|
+
orderBy: (column, direction, nulls) => new SdkQueryBuilder(client).orderBy(column, direction, nulls),
|
|
4838
6127
|
limit: (count) => new SdkQueryBuilder(client).limit(count),
|
|
4839
6128
|
offset: (count) => new SdkQueryBuilder(client).offset(count),
|
|
4840
6129
|
search: (searchString) => new SdkQueryBuilder(client).search(searchString),
|
|
4841
6130
|
vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
|
|
4842
|
-
include: (...relations) => new SdkQueryBuilder(client).include(...relations)
|
|
6131
|
+
include: (...relations) => new SdkQueryBuilder(client).include(...relations),
|
|
6132
|
+
fields: (...columns) => new SdkQueryBuilder(client).fields(...columns),
|
|
6133
|
+
distinct: (enabled) => new SdkQueryBuilder(client).distinct(enabled),
|
|
6134
|
+
after: (cursor) => new SdkQueryBuilder(client).after(cursor),
|
|
6135
|
+
aggregate: snap.aggregate ? (params) => snap.aggregate(params) : unsupportedMethod(noAggregate(slug))
|
|
4843
6136
|
};
|
|
4844
6137
|
return client;
|
|
4845
6138
|
}
|
|
@@ -4927,6 +6220,6 @@ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
|
|
|
4927
6220
|
} });
|
|
4928
6221
|
}
|
|
4929
6222
|
//#endregion
|
|
4930
|
-
export {
|
|
6223
|
+
export { isFieldOperation as $, createDataSourceRegistry as A, resolveCollectionRelations as B, decodeCursor as C, restrictedFieldNames as D, effectiveAccess as E, securityRuleToConditions as F, suggestNearMiss as G, buildCompositeId as H, fieldKeyForColumn as I, MAX_LIST_LIMIT as J, toSnakeCase as K, findRelation as L, resolveDataSource as M, getEffectiveSecurityRules as N, defaultUsersCollection as O, getTenantConfig as P, hasFieldOperation as Q, getTableName as R, cursorToStartAfter as S, canWriteField as T, resolvePrimaryKeys as U, enumToObjectEntries as V, hydrateRegExp as W, BATCH_REF_KEY as X, resolveClientListLimit as Y, FIELD_OPERATORS as Z, OrderBySpecError as _, deserializeLogicalCondition as a, Vector as at, CursorError as b, collectAllPages as c, isUnsupported as ct, IncludeSpecError as d, ANONYMOUS_USER_ID as et, deserializeInclude as f, topLevelIncludeNames as g, serializeInclude as h, deserializeFilter as i, GeoPoint as it, isRelationalCollection as j, CollectionRegistry as k, paginateFind as l, unsupportedMethod as lt, normalizeInclude as m, buildSdkData as n, EntityReference as nt, serializeFilter as o, RebaseApiError as ot, mergeIncludeSpecs as p, ListLimitError as q, UnknownFilterOperatorError as r, EntityRelation as rt, serializeLogicalCondition as s, RebaseClientError as st, buildRoutedRebaseData as t, isAnonymousUid as tt, resolveFindWindow as u, normalizeOrderBy as v, reconcileCursorOrder as w, CursorMismatchError as x, serializeOrderBy as y, isRelationRequired as z };
|
|
4931
6224
|
|
|
4932
|
-
//# sourceMappingURL=src-
|
|
6225
|
+
//# sourceMappingURL=src-DqZ9YiGA.js.map
|