@rebasepro/server-postgres 0.13.1-canary.g18cfeb7 → 0.13.1-canary.g249daa1
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/{auth-users-columns-Do9mw5Y5.js → auth-users-columns-Dt9g712t.js} +405 -7
- package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/{ensure-collection-policies-DMUdRQdM.js → ensure-collection-policies-CwYUliAa.js} +2 -2
- package/dist/{ensure-collection-policies-DMUdRQdM.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
- package/dist/{ensure-collection-tables-DSIxvLLD.js → ensure-collection-tables-DRxaUG96.js} +34 -7
- package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
- package/dist/index.es.js +234 -26
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/search-column.d.ts +199 -0
- package/dist/services/FetchService.d.ts +15 -0
- package/dist/services/dataService.d.ts +1 -0
- package/dist/services/realtimeService.d.ts +2 -0
- package/dist/src-C_wvdMnl.js.map +1 -1
- package/dist/utils/drizzle-conditions.d.ts +71 -2
- package/dist/websocket-D0TBU3ia.js.map +1 -1
- package/package.json +6 -6
- package/src/cli-helpers.ts +107 -1
- package/src/cli.ts +57 -0
- package/src/data-transformer.ts +18 -0
- package/src/schema/ensure-collection-tables.ts +77 -1
- package/src/schema/generate-drizzle-schema-logic.ts +25 -1
- package/src/schema/generate-postgres-ddl-logic.ts +176 -9
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/search-column.ts +558 -0
- package/src/services/FetchService.ts +138 -15
- package/src/services/RelationService.ts +2 -1
- package/src/services/dataService.ts +1 -0
- package/src/services/realtimeService.ts +10 -4
- package/src/utils/drizzle-conditions.ts +223 -2
- package/dist/auth-users-columns-Do9mw5Y5.js.map +0 -1
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +0 -1
|
@@ -1408,6 +1408,28 @@ function legacyForeignKeyName(name) {
|
|
|
1408
1408
|
const snake = toSnakeCase(name);
|
|
1409
1409
|
return `${snake.endsWith("s") ? snake.slice(0, -1) : snake}_id`;
|
|
1410
1410
|
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Truncate an identifier to what Postgres will actually store.
|
|
1413
|
+
*
|
|
1414
|
+
* Postgres silently truncates identifiers at NAMEDATALEN-1 = 63 **bytes**, so a
|
|
1415
|
+
* name generated longer than that is not the name the database ends up holding.
|
|
1416
|
+
* Anything that later looks the object up by the name it generated then misses.
|
|
1417
|
+
*
|
|
1418
|
+
* Byte length, not string length: NAMEDATALEN is a byte bound, and a multi-byte
|
|
1419
|
+
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
1420
|
+
*
|
|
1421
|
+
* `TextEncoder` rather than `Buffer`, which is not a matter of taste: `Buffer`
|
|
1422
|
+
* is a Node global, and this package is imported by browser-facing ones. It
|
|
1423
|
+
* typechecked only where `@types/node` happened to be in scope, so
|
|
1424
|
+
* `packages/codegen` — whose tsconfig is `lib: ["ESNext", "dom"]` — could not
|
|
1425
|
+
* compile the file at all, and both of its suites failed to run. `TextEncoder`
|
|
1426
|
+
* and `TextDecoder` are standard in both runtimes and need no ambient types.
|
|
1427
|
+
*/
|
|
1428
|
+
function toPostgresIdentifier(name) {
|
|
1429
|
+
const bytes = new TextEncoder().encode(name);
|
|
1430
|
+
if (bytes.byteLength <= 63) return name;
|
|
1431
|
+
return new TextDecoder("utf-8").decode(bytes.subarray(0, 63)).replace(/�+$/, "");
|
|
1432
|
+
}
|
|
1411
1433
|
//#endregion
|
|
1412
1434
|
//#region ../common/src/util/entities.ts
|
|
1413
1435
|
/**
|
|
@@ -3708,8 +3730,24 @@ var QueryBuilder = class {
|
|
|
3708
3730
|
/**
|
|
3709
3731
|
* Set a free-text search string if supported by the backend.
|
|
3710
3732
|
*/
|
|
3711
|
-
search(searchString) {
|
|
3733
|
+
search(searchString, options) {
|
|
3712
3734
|
this.params.searchString = searchString;
|
|
3735
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
3736
|
+
return this;
|
|
3737
|
+
}
|
|
3738
|
+
/**
|
|
3739
|
+
* Order rows by nearest-neighbour distance to `vector`, closest first.
|
|
3740
|
+
*
|
|
3741
|
+
* Postgres only, over a property declared as `type: "vector"`. Rows come
|
|
3742
|
+
* back with a `_distance`; `where` filters before the ordering.
|
|
3743
|
+
*/
|
|
3744
|
+
vectorSearch(property, vector, options) {
|
|
3745
|
+
this.params.vectorSearch = {
|
|
3746
|
+
property,
|
|
3747
|
+
vector,
|
|
3748
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
3749
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
3750
|
+
};
|
|
3713
3751
|
return this;
|
|
3714
3752
|
}
|
|
3715
3753
|
/**
|
|
@@ -4039,10 +4077,12 @@ function createPrimaryKeyResolver(options) {
|
|
|
4039
4077
|
* than postgres still serve rows with one, and this keeps them working.
|
|
4040
4078
|
*/
|
|
4041
4079
|
function rowToEntity(row, slug, primaryKeys = []) {
|
|
4080
|
+
const { _matches, ...values } = row;
|
|
4042
4081
|
return {
|
|
4043
4082
|
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
4044
4083
|
path: slug,
|
|
4045
|
-
values
|
|
4084
|
+
values,
|
|
4085
|
+
..._matches ? { searchMatches: _matches } : {}
|
|
4046
4086
|
};
|
|
4047
4087
|
}
|
|
4048
4088
|
/**
|
|
@@ -4200,6 +4240,7 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4200
4240
|
orderBy: params?.orderBy?.[0],
|
|
4201
4241
|
order: params?.orderBy?.[1],
|
|
4202
4242
|
searchString: params?.searchString,
|
|
4243
|
+
searchExplain: params?.searchExplain,
|
|
4203
4244
|
onUpdate: (entities) => {
|
|
4204
4245
|
onUpdate({
|
|
4205
4246
|
data: entities.map((row) => rowToEntity(normalize(row), slug, getPks())),
|
|
@@ -4237,8 +4278,11 @@ function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
|
4237
4278
|
offset(count) {
|
|
4238
4279
|
return new QueryBuilder(accessor).offset(count);
|
|
4239
4280
|
},
|
|
4240
|
-
search(searchString) {
|
|
4241
|
-
return new QueryBuilder(accessor).search(searchString);
|
|
4281
|
+
search(searchString, options) {
|
|
4282
|
+
return new QueryBuilder(accessor).search(searchString, options);
|
|
4283
|
+
},
|
|
4284
|
+
vectorSearch(property, vector, options) {
|
|
4285
|
+
return new QueryBuilder(accessor).vectorSearch(property, vector, options);
|
|
4242
4286
|
},
|
|
4243
4287
|
include(...relations) {
|
|
4244
4288
|
return new QueryBuilder(accessor).include(...relations);
|
|
@@ -4326,8 +4370,18 @@ var SdkQueryBuilder = class {
|
|
|
4326
4370
|
this.params.offset = count;
|
|
4327
4371
|
return this;
|
|
4328
4372
|
}
|
|
4329
|
-
search(searchString) {
|
|
4373
|
+
search(searchString, options) {
|
|
4330
4374
|
this.params.searchString = searchString;
|
|
4375
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
4376
|
+
return this;
|
|
4377
|
+
}
|
|
4378
|
+
vectorSearch(property, vector, options) {
|
|
4379
|
+
this.params.vectorSearch = {
|
|
4380
|
+
property,
|
|
4381
|
+
vector,
|
|
4382
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
4383
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
4384
|
+
};
|
|
4331
4385
|
return this;
|
|
4332
4386
|
}
|
|
4333
4387
|
include(...relations) {
|
|
@@ -4414,6 +4468,7 @@ function toSdkCollectionClient(snap, slug = "collection") {
|
|
|
4414
4468
|
limit: (count) => new SdkQueryBuilder(client).limit(count),
|
|
4415
4469
|
offset: (count) => new SdkQueryBuilder(client).offset(count),
|
|
4416
4470
|
search: (searchString) => new SdkQueryBuilder(client).search(searchString),
|
|
4471
|
+
vectorSearch: (property, vector, options) => new SdkQueryBuilder(client).vectorSearch(property, vector, options),
|
|
4417
4472
|
include: (...relations) => new SdkQueryBuilder(client).include(...relations)
|
|
4418
4473
|
};
|
|
4419
4474
|
return client;
|
|
@@ -4459,6 +4514,349 @@ function buildSdkData(driver) {
|
|
|
4459
4514
|
return wrapAsSdkData(buildRebaseData(driver));
|
|
4460
4515
|
}
|
|
4461
4516
|
//#endregion
|
|
4517
|
+
//#region src/schema/search-column.ts
|
|
4518
|
+
/**
|
|
4519
|
+
* The one place a collection's `search` block becomes SQL.
|
|
4520
|
+
*
|
|
4521
|
+
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
4522
|
+
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
4523
|
+
* the boot-time schema ensure — and each of them has, at some point, described
|
|
4524
|
+
* a column differently from the others. The `varchar(255)` note in
|
|
4525
|
+
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
4526
|
+
* capped column down one path and an uncapped one down the other, and nothing
|
|
4527
|
+
* failed until a user hit the cap.
|
|
4528
|
+
*
|
|
4529
|
+
* So the search column is not implemented four times. It is computed once,
|
|
4530
|
+
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
4531
|
+
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
4532
|
+
* this module is that the test has something to assert *about*.
|
|
4533
|
+
*
|
|
4534
|
+
* ## Why the expressions look the way they do
|
|
4535
|
+
*
|
|
4536
|
+
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
4537
|
+
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
4538
|
+
*
|
|
4539
|
+
* | expression | immutable |
|
|
4540
|
+
* |-----------------------------------------|-----------|
|
|
4541
|
+
* | `to_tsvector('spanish', col)` | yes |
|
|
4542
|
+
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
4543
|
+
* | `array_to_string(col, ' ')` | **no** |
|
|
4544
|
+
* | `col::text` on `text[]` | **no** |
|
|
4545
|
+
* | `to_jsonb(col)` | **no** |
|
|
4546
|
+
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
4547
|
+
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
4548
|
+
* | `setweight(...) || setweight(...)` | yes |
|
|
4549
|
+
*
|
|
4550
|
+
* Three of the four things a real search column needs are therefore unavailable
|
|
4551
|
+
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
4552
|
+
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
4553
|
+
* promise, and it is a true one for these three — array joining, JSON string
|
|
4554
|
+
* extraction and accent folding are all deterministic for a given input; the
|
|
4555
|
+
* built-ins are marked stable only because they must account for element types
|
|
4556
|
+
* and dictionaries in general.
|
|
4557
|
+
*
|
|
4558
|
+
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
4559
|
+
* a real option in an accented language: Postgres stems `auditoría` to
|
|
4560
|
+
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
4561
|
+
* typed without accents misses every row that carries them.
|
|
4562
|
+
*/
|
|
4563
|
+
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
4564
|
+
var HELPER_SCHEMA = "public";
|
|
4565
|
+
/**
|
|
4566
|
+
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
4567
|
+
* generation expression of every search column ever created, so renaming one
|
|
4568
|
+
* orphans every table that already has a search column.
|
|
4569
|
+
*/
|
|
4570
|
+
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
4571
|
+
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
4572
|
+
/** Raised when a `search` block names something that cannot be searched. */
|
|
4573
|
+
var SearchConfigError = class extends Error {
|
|
4574
|
+
constructor(message) {
|
|
4575
|
+
super(message);
|
|
4576
|
+
this.name = "SearchConfigError";
|
|
4577
|
+
}
|
|
4578
|
+
};
|
|
4579
|
+
/** The `search` block of a collection, or undefined when it has none. */
|
|
4580
|
+
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
4581
|
+
/**
|
|
4582
|
+
* Refuse a `search` block on a collection this engine does not store.
|
|
4583
|
+
*
|
|
4584
|
+
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
4585
|
+
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
4586
|
+
* a collection whose `engine` was changed after the block was written — because
|
|
4587
|
+
* the alternative is the exact failure the block exists to prevent: a developer
|
|
4588
|
+
* who declared what to index, saw no error, and got the substring fallback.
|
|
4589
|
+
*
|
|
4590
|
+
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
4591
|
+
*/
|
|
4592
|
+
var assertSearchIsPostgresOnly = (collections) => {
|
|
4593
|
+
for (const collection of collections) {
|
|
4594
|
+
if (isPostgresCollectionConfig(collection)) continue;
|
|
4595
|
+
if (!collection.search) continue;
|
|
4596
|
+
const engine = collection.engine ?? "non-postgres";
|
|
4597
|
+
throw new SearchConfigError(`${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \`${engine}\`. Remove the block — it would otherwise look configured while \`.search()\` kept using the default substring match.`);
|
|
4598
|
+
}
|
|
4599
|
+
};
|
|
4600
|
+
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
4601
|
+
/**
|
|
4602
|
+
* Classify a property for search purposes.
|
|
4603
|
+
*
|
|
4604
|
+
* Deliberately narrower than `getSqlColumnType`: search only cares whether a
|
|
4605
|
+
* value reaches text, and the mapping from property to *physical* type is
|
|
4606
|
+
* asserted against `getSqlColumnType` in the contract test rather than
|
|
4607
|
+
* duplicated here.
|
|
4608
|
+
*
|
|
4609
|
+
* Returns null for anything that is not text-bearing, which the caller turns
|
|
4610
|
+
* into a boot error naming the property.
|
|
4611
|
+
*/
|
|
4612
|
+
var classify = (prop) => {
|
|
4613
|
+
switch (prop.type) {
|
|
4614
|
+
case "string": {
|
|
4615
|
+
const sp = prop;
|
|
4616
|
+
if (sp.enum) return {
|
|
4617
|
+
kind: "text",
|
|
4618
|
+
reason: "enum"
|
|
4619
|
+
};
|
|
4620
|
+
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
4621
|
+
kind: "text",
|
|
4622
|
+
reason: "uuid"
|
|
4623
|
+
};
|
|
4624
|
+
return { kind: "text" };
|
|
4625
|
+
}
|
|
4626
|
+
case "map":
|
|
4627
|
+
if (prop.columnType === "json") return {
|
|
4628
|
+
kind: "jsonb",
|
|
4629
|
+
reason: "json"
|
|
4630
|
+
};
|
|
4631
|
+
return { kind: "jsonb" };
|
|
4632
|
+
case "array": {
|
|
4633
|
+
const ap = prop;
|
|
4634
|
+
let colType = ap.columnType;
|
|
4635
|
+
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
4636
|
+
const of = ap.of;
|
|
4637
|
+
if (of.type === "string") colType = "text[]";
|
|
4638
|
+
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
4639
|
+
else if (of.type === "boolean") colType = "boolean[]";
|
|
4640
|
+
}
|
|
4641
|
+
if (colType === "text[]") return { kind: "text_array" };
|
|
4642
|
+
if (colType === "json") return {
|
|
4643
|
+
kind: "jsonb",
|
|
4644
|
+
reason: "json"
|
|
4645
|
+
};
|
|
4646
|
+
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
4647
|
+
kind: "text_array",
|
|
4648
|
+
reason: "non_text_array"
|
|
4649
|
+
};
|
|
4650
|
+
return { kind: "jsonb" };
|
|
4651
|
+
}
|
|
4652
|
+
default: return null;
|
|
4653
|
+
}
|
|
4654
|
+
};
|
|
4655
|
+
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
4656
|
+
/** SQL reading one field as plain text, before normalization. */
|
|
4657
|
+
var rawTextSql = (field) => {
|
|
4658
|
+
const col = `"${field.column}"`;
|
|
4659
|
+
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
4660
|
+
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
4661
|
+
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote(field.jsonPath[0])}` : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
4662
|
+
};
|
|
4663
|
+
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
4664
|
+
/**
|
|
4665
|
+
* Resolve and validate one declared field path.
|
|
4666
|
+
*
|
|
4667
|
+
* A path that does not resolve throws. The whole point of an explicit block is
|
|
4668
|
+
* that the author knows what is indexed; a silently dropped field would make it
|
|
4669
|
+
* a guess again, and the failure — a search that returns nothing for content
|
|
4670
|
+
* that is plainly in the row — is invisible from the outside.
|
|
4671
|
+
*/
|
|
4672
|
+
var resolveField = (entry, collection, cfg) => {
|
|
4673
|
+
const path = typeof entry === "string" ? entry : entry.path;
|
|
4674
|
+
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
|
|
4675
|
+
const where = `${collection.slug}.search`;
|
|
4676
|
+
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
4677
|
+
const [head, ...rest] = path.split(".");
|
|
4678
|
+
const prop = collection.properties?.[head];
|
|
4679
|
+
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
4680
|
+
const classified = classify(prop);
|
|
4681
|
+
if (!classified) throw new SearchConfigError(`${where}: "${path}" is a \`${prop.type}\` property, which holds no text to search. Searchable kinds are \`string\`, \`string[]\` and \`map\` (or a path inside one).`);
|
|
4682
|
+
if (classified.reason === "enum") throw new SearchConfigError(`${where}: "${path}" is an enum. Enums are a fixed vocabulary — filter on them with \`where\` instead, which is exact and uses an index.`);
|
|
4683
|
+
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
4684
|
+
if (classified.reason === "json") throw new SearchConfigError(`${where}: "${path}" is a \`json\` column, and the cast from \`json\` to \`jsonb\` is not immutable, so it cannot feed a generated column. Declare the property as \`jsonb\` (the default) to search it.`);
|
|
4685
|
+
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
4686
|
+
if (rest.length > 0 && classified.kind !== "jsonb") throw new SearchConfigError(`${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`);
|
|
4687
|
+
const column = columnNameOf(head, prop);
|
|
4688
|
+
const textSql = normalize(rawTextSql({
|
|
4689
|
+
column,
|
|
4690
|
+
jsonPath: rest,
|
|
4691
|
+
kind: classified.kind
|
|
4692
|
+
}), cfg.unaccent === true);
|
|
4693
|
+
const language = cfg.language ?? "simple";
|
|
4694
|
+
return {
|
|
4695
|
+
path,
|
|
4696
|
+
column,
|
|
4697
|
+
jsonPath: rest,
|
|
4698
|
+
kind: classified.kind,
|
|
4699
|
+
weight,
|
|
4700
|
+
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
4701
|
+
textSql
|
|
4702
|
+
};
|
|
4703
|
+
};
|
|
4704
|
+
/**
|
|
4705
|
+
* Build the full spec for a collection, or undefined when it has not opted in.
|
|
4706
|
+
*
|
|
4707
|
+
* Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
|
|
4708
|
+
* at boot surface that as a startup failure — a search block that half-works is
|
|
4709
|
+
* worse than one that refuses.
|
|
4710
|
+
*/
|
|
4711
|
+
var buildSearchColumnSpec = (collection) => {
|
|
4712
|
+
const cfg = getSearchConfig(collection);
|
|
4713
|
+
if (!cfg) return void 0;
|
|
4714
|
+
if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
|
|
4715
|
+
const table = getTableName(collection);
|
|
4716
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
4717
|
+
const column = cfg.column ?? "search_vector";
|
|
4718
|
+
if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
|
|
4719
|
+
const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
|
|
4720
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4721
|
+
for (const f of fields) {
|
|
4722
|
+
if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
|
|
4723
|
+
seen.add(f.path);
|
|
4724
|
+
}
|
|
4725
|
+
const extensions = [];
|
|
4726
|
+
if (cfg.unaccent) extensions.push("unaccent");
|
|
4727
|
+
if (cfg.fuzzy) extensions.push("pg_trgm");
|
|
4728
|
+
const spec = {
|
|
4729
|
+
schema,
|
|
4730
|
+
table,
|
|
4731
|
+
column,
|
|
4732
|
+
language: cfg.language ?? "simple",
|
|
4733
|
+
unaccent: cfg.unaccent === true,
|
|
4734
|
+
fields,
|
|
4735
|
+
expression: fields.map((f) => f.sql).join(" || "),
|
|
4736
|
+
indexName: toPostgresIdentifier(`${table}_${column}_gin`),
|
|
4737
|
+
extensions
|
|
4738
|
+
};
|
|
4739
|
+
if (cfg.fuzzy) {
|
|
4740
|
+
const fuzzyColumn = `${column}_text`;
|
|
4741
|
+
if (collection.properties?.[fuzzyColumn]) throw new SearchConfigError(`${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`);
|
|
4742
|
+
spec.fuzzy = {
|
|
4743
|
+
column: fuzzyColumn,
|
|
4744
|
+
expression: fields.map((f) => f.textSql).join(" || ' ' || "),
|
|
4745
|
+
indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
|
|
4746
|
+
threshold: cfg.fuzzyThreshold ?? .3
|
|
4747
|
+
};
|
|
4748
|
+
}
|
|
4749
|
+
return spec;
|
|
4750
|
+
};
|
|
4751
|
+
/**
|
|
4752
|
+
* The IMMUTABLE wrappers the generated expressions call.
|
|
4753
|
+
*
|
|
4754
|
+
* `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
|
|
4755
|
+
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
4756
|
+
* statement here is.
|
|
4757
|
+
*
|
|
4758
|
+
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
4759
|
+
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
4760
|
+
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
4761
|
+
* each call site then absorbs.
|
|
4762
|
+
*/
|
|
4763
|
+
var searchHelperFunctions = (spec) => {
|
|
4764
|
+
const statements = [`CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT array_to_string($1, ' ') $$;`, `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT coalesce(string_agg(v, ' '), '')\n FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`];
|
|
4765
|
+
if (spec.unaccent) statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
|
|
4766
|
+
return statements;
|
|
4767
|
+
};
|
|
4768
|
+
/**
|
|
4769
|
+
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
4770
|
+
*
|
|
4771
|
+
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
4772
|
+
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
4773
|
+
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
4774
|
+
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
4775
|
+
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
4776
|
+
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
4777
|
+
* with "text search dictionary does not exist". Observed, not theorised.
|
|
4778
|
+
*/
|
|
4779
|
+
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
4780
|
+
/**
|
|
4781
|
+
* Index statements for the spec.
|
|
4782
|
+
*
|
|
4783
|
+
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
4784
|
+
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
4785
|
+
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
4786
|
+
* statement against tables that are live and populated, and uses the
|
|
4787
|
+
* concurrent form instead; see `ensureSearchColumns`.
|
|
4788
|
+
*/
|
|
4789
|
+
var searchIndexStatements = (spec) => {
|
|
4790
|
+
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
4791
|
+
if (spec.fuzzy) statements.push(`CREATE INDEX IF NOT EXISTS "${spec.fuzzy.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.fuzzy.column}" ${HELPER_SCHEMA}.gin_trgm_ops);`);
|
|
4792
|
+
return statements;
|
|
4793
|
+
};
|
|
4794
|
+
/**
|
|
4795
|
+
* The generated column names a collection's search block adds, if any.
|
|
4796
|
+
*
|
|
4797
|
+
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
4798
|
+
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
4799
|
+
* every searchable field on the row — and nothing outside the query planner has
|
|
4800
|
+
* any use for them. Left in, every list response carries a second, larger copy
|
|
4801
|
+
* of the row's text.
|
|
4802
|
+
*/
|
|
4803
|
+
var searchColumnNames = (collection) => {
|
|
4804
|
+
let spec;
|
|
4805
|
+
try {
|
|
4806
|
+
spec = buildSearchColumnSpec(collection);
|
|
4807
|
+
} catch {
|
|
4808
|
+
return [];
|
|
4809
|
+
}
|
|
4810
|
+
if (!spec) return [];
|
|
4811
|
+
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
4812
|
+
};
|
|
4813
|
+
/**
|
|
4814
|
+
* True for a column whose type only ever holds a search index.
|
|
4815
|
+
*
|
|
4816
|
+
* Independent of any collection config on purpose: an introspected database
|
|
4817
|
+
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
4818
|
+
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
4819
|
+
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
4820
|
+
* the *properties*; this keeps it out of the *rows*.
|
|
4821
|
+
*/
|
|
4822
|
+
var isSearchIndexColumn = (column) => {
|
|
4823
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
4824
|
+
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
4825
|
+
};
|
|
4826
|
+
/**
|
|
4827
|
+
* A drizzle select projection over `table` with the search columns dropped.
|
|
4828
|
+
*
|
|
4829
|
+
* Returns undefined when nothing needs dropping, so the common case keeps using
|
|
4830
|
+
* a plain `select()` and this stays invisible in the generated SQL.
|
|
4831
|
+
*/
|
|
4832
|
+
var visibleColumnProjection = (tableColumns, collection) => {
|
|
4833
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
4834
|
+
if (!tableColumns || excluded.length === 0) return void 0;
|
|
4835
|
+
const projection = {};
|
|
4836
|
+
for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
|
|
4837
|
+
return projection;
|
|
4838
|
+
};
|
|
4839
|
+
/** The same exclusion as a drizzle `db.query` `columns` denylist. */
|
|
4840
|
+
var hiddenColumnsOption = (tableColumns, collection) => {
|
|
4841
|
+
const excluded = excludedColumnNames(tableColumns, collection);
|
|
4842
|
+
if (excluded.length === 0) return void 0;
|
|
4843
|
+
return Object.fromEntries(excluded.map((name) => [name, false]));
|
|
4844
|
+
};
|
|
4845
|
+
/**
|
|
4846
|
+
* The columns to keep out of a response, by name.
|
|
4847
|
+
*
|
|
4848
|
+
* `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
|
|
4849
|
+
* for anything that is not a real drizzle table — a stub in a test, a derived
|
|
4850
|
+
* or nested path with no table behind it. Nothing to exclude is the right
|
|
4851
|
+
* answer there, and it has to be an answer rather than a throw: this runs on
|
|
4852
|
+
* the read path of every collection, opted in or not.
|
|
4853
|
+
*/
|
|
4854
|
+
var excludedColumnNames = (tableColumns, collection) => {
|
|
4855
|
+
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
4856
|
+
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
4857
|
+
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
4858
|
+
};
|
|
4859
|
+
//#endregion
|
|
4462
4860
|
//#region src/schema/auth-users-columns.ts
|
|
4463
4861
|
/**
|
|
4464
4862
|
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
@@ -4570,6 +4968,6 @@ function isAuthCollection(collection) {
|
|
|
4570
4968
|
return typeof auth === "object" && auth !== null && auth.enabled === true;
|
|
4571
4969
|
}
|
|
4572
4970
|
//#endregion
|
|
4573
|
-
export {
|
|
4971
|
+
export { resolveCollectionRelations as A, legacyForeignKeyName as B, securityRuleToConditions as C, getEnumVarName as D, getColumnName as E, createRelationRef as F, camelCase as G, getPolicyNamesForRule as H, createRelationRefWithData as I, DEFAULT_ONE_OF_VALUE as J, toSnakeCase as K, normalizeToEntityRelation as L, getDeclaredPrimaryKeys as M, isAddressableId as N, getTableName as O, parseIdValues as P, Vector as Q, updateDateAutoValues as R, policyToPostgres as S, findRelation as T, isPrototypePollutingKey as U, toPostgresIdentifier as V, mergeDeep as W, hasForeignKeyOnTarget as X, resolveClientListLimit as Y, isManyToMany as Z, resolveStringColumnLength as _, SEARCH_TEXT_FN as a, resolveJunctionSpecs as b, buildSearchColumnSpec as c, searchHelperFunctions as d, searchIndexStatements as f, relationalCollections as g, CollectionRegistry as h, isAuthCollection as i, buildCompositeId as j, getTableVarName as k, hiddenColumnsOption as l, buildSdkData as m, authUsersColumnDefinition as n, SEARCH_UNACCENT_FN as o, visibleColumnProjection as p, DEFAULT_ONE_OF_TYPE as q, authUsersColumnSql as r, assertSearchIsPostgresOnly as s, AUTH_USERS_COLUMNS as t, searchExtensionStatements as u, getJunctionCollectionConfig as v, findAnonymousGrants as w, getEffectiveSecurityRules as x, getJunctionSecurityRules as y, generateForeignKeyName as z };
|
|
4574
4972
|
|
|
4575
|
-
//# sourceMappingURL=auth-users-columns-
|
|
4973
|
+
//# sourceMappingURL=auth-users-columns-Dt9g712t.js.map
|