@rebasepro/common 0.13.0 → 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.
@@ -3,7 +3,7 @@ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBC
3
3
  * Define a PostgreSQL-backed collection with full type inference.
4
4
  *
5
5
  * The `const P` generic captures literal property types from your
6
- * `properties` object, which enables autocomplete on `titleProperty`,
6
+ * `properties` object, which enables autocomplete on `display.title`,
7
7
  * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
8
8
  *
9
9
  * @example
@@ -16,7 +16,7 @@ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBC
16
16
  * name: { name: "Name", type: "string", validation: { required: true } },
17
17
  * price: { name: "Price", type: "number" },
18
18
  * },
19
- * titleProperty: "name", // ✅ autocomplete: "name" | "price"
19
+ * display: { title: "name" }, // ✅ autocomplete: "name" | "price"
20
20
  * sort: ["price", "asc"], // ✅ autocomplete on first element
21
21
  * });
22
22
  * ```
@@ -1,13 +1,17 @@
1
- import { AuthState, ConditionContext, JsonLogicRule } from "@rebasepro/types";
1
+ import { AuthState, ConditionContext, ConditionRule } from "@rebasepro/types";
2
2
  /**
3
3
  * Register custom JSON Logic operations for Rebase.
4
4
  * Call this once at app initialization.
5
5
  */
6
6
  export declare function registerConditionOperations(): void;
7
7
  /**
8
- * Evaluate a JSON Logic rule against the given context.
8
+ * Evaluate a condition against the given context.
9
+ *
10
+ * A condition may be stated as a literal instead of a rule — `hidden: true`
11
+ * rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
12
+ * answer, so it is returned rather than handed to the evaluator.
9
13
  */
10
- export declare function evaluateCondition(rule: JsonLogicRule, context: ConditionContext): unknown;
14
+ export declare function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown;
11
15
  /**
12
16
  * Build a ConditionContext from the current property resolution context.
13
17
  */
@@ -31,9 +31,16 @@ export declare function getRelationFrom<M extends Record<string, unknown>>(entit
31
31
  * have `id` and `path` fields — these are relation-shaped objects from
32
32
  * edge cases in the data pipeline (REST fallback, stale cache, custom data source).
33
33
  *
34
+ * When `targetPath` is given, also accepts a bare id. A relation column is a
35
+ * foreign key, and the REST layer returns it as the scalar it is; only some
36
+ * fetch paths hydrate it into an object. Which form a caller sees therefore
37
+ * depends on how the row was loaded, and a caller that only accepted objects
38
+ * reported half of its own data as a type error. The declared target is the
39
+ * missing half: with it, an id is a relation that has not been fetched yet.
40
+ *
34
41
  * Returns null if the value cannot be coerced.
35
42
  */
36
- export declare function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null;
43
+ export declare function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null;
37
44
  export declare function traverseValuesProperties<M extends Record<string, unknown>>(inputValues: Partial<EntityValues<M>>, properties: Properties, operation: (value: unknown, property: Property) => unknown): EntityValues<M> | undefined;
38
45
  export declare function traverseValueProperty(inputValue: unknown, property: Property, operation: (value: unknown, property: Property) => unknown): unknown;
39
46
  /**
@@ -18,3 +18,4 @@ export * from "./junction-policies";
18
18
  export * from "./conditions";
19
19
  export * from "./pg-column-to-property";
20
20
  export * from "./string-column-length";
21
+ export * from "./internal-tables";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
3
+ * end-user role away from them.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
8
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
9
+ * in the schemas a project uses — including `rebase`, because a project's own
10
+ * collections are allowed to live there (the scaffold puts `users` there). It
11
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
12
+ * migrating role inherits the same grant.
13
+ *
14
+ * Every framework-internal table is created later: auth's tables come up during
15
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
16
+ * job registers, `idempotency_keys` on the first request that carries a key. So
17
+ * they all inherited full DML for the end-user role — and none of them enables
18
+ * row-level security, because none of them is a collection with
19
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
20
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
21
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
22
+ * `rebase.api_keys` (including its `admin` flag), and insert into
23
+ * `rebase.app_config`.
24
+ *
25
+ * Nothing routes a user-context query at those tables today, so this was not
26
+ * reachable over the API. That is the wrong thing to depend on: the documented
27
+ * model is that RLS is the authorization boundary, and these tables sat outside
28
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
29
+ * address them.
30
+ *
31
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
32
+ *
33
+ * RLS with no policy denies every row, which is the same outcome, but it is the
34
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
35
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
36
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
37
+ * is "this role has no privilege here at all". It also keeps the owner
38
+ * connection (which auth actually runs on) completely unaffected.
39
+ *
40
+ * ## Keeping it true
41
+ *
42
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
43
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
44
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
45
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
46
+ * someone re-reading this file.
47
+ */
48
+ /**
49
+ * The Postgres role authenticated requests run as.
50
+ *
51
+ * Defined here rather than in the Postgres driver because both the driver (which
52
+ * provisions the role) and this module (which revokes on its behalf) need it,
53
+ * and a second spelling of a role name is a silent no-op waiting to happen.
54
+ */
55
+ export declare const REBASE_USER_ROLE = "rebase_user";
56
+ /**
57
+ * Framework-internal table names, unqualified.
58
+ *
59
+ * Deliberately NOT including `users`: the auth user table is also a collection,
60
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
61
+ * row through it — revoking there would break sign-in.
62
+ *
63
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
64
+ * because `db migrate apply` passes `--revisions-schema rebase`.
65
+ */
66
+ export declare const REBASE_INTERNAL_TABLES: readonly string[];
67
+ /**
68
+ * A single statement that takes every privilege on `schema.table` away from the
69
+ * end-user role.
70
+ *
71
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
72
+ * happen in practice:
73
+ *
74
+ * - the role does not exist when the connection is unprivileged (Rebase then
75
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
76
+ * missing role is an error, not a no-op;
77
+ * - the table may not exist yet — `cron_logs` never appears in a project with
78
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
79
+ *
80
+ * One command, so it is safe on handles that speak the extended query protocol
81
+ * and reject multi-statement strings.
82
+ */
83
+ export declare function revokeInternalTableSql(schema: string, table: string): string;
84
+ /**
85
+ * Revoke on every internal table in `schema`, one statement at a time.
86
+ *
87
+ * Best-effort per table: a connection that does not own one of them (a
88
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
89
+ * that must not take down a boot. The caller decides how loud to be — `onError`
90
+ * exists so the driver can warn without this module importing a logger.
91
+ */
92
+ export declare function revokeInternalTableAccess(execute: (sql: string) => Promise<unknown>, schema: string, options?: {
93
+ tables?: readonly string[];
94
+ onError?: (table: string, error: unknown) => void;
95
+ }): Promise<void>;
@@ -12,13 +12,13 @@ export interface AnonymousGrantRisk {
12
12
  /**
13
13
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
14
14
  *
15
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
16
- * is NULL for an anonymous request. Rebase substitutes
15
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
16
+ * really is NULL for an anonymous request. Rebase substitutes
17
17
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
18
18
  * is how the trusted *server* context is recognised), so:
19
19
  *
20
- * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
21
- * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
20
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
21
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
22
22
  * other. This one is not hypothetical and was not only a foreign habit:
23
23
  * rebase's own request path reported `'anon'` while everything that compiled
24
24
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
1
+ import { CollectionConfig, ResolvedRelation, RelationProperty } from "@rebasepro/types";
2
2
  /**
3
3
  * Whether the target rows are shared with other parents — a many-to-many, or a
4
4
  * multi-hop `via` chain.
@@ -27,6 +27,23 @@ export declare function isJunctionBackedRelation(relation: ResolvedRelation): bo
27
27
  * which is worth hearing about.
28
28
  */
29
29
  export declare function resolveCollectionRelations(collection: CollectionConfig): Record<string, ResolvedRelation>;
30
+ /**
31
+ * The path of the collection a relation property points at, derived from the
32
+ * property alone.
33
+ *
34
+ * A preview holds a property and a value and no collection, so it cannot call
35
+ * `resolveRelationProperty`. It does not need to: both forms that carry a
36
+ * target — the stamped `resolvedRelation` and the inline `relation` — name it
37
+ * directly. Only the third form, a relation declared by name in the
38
+ * collection's `relations` array, is out of reach, and that one has no target
39
+ * to read without the collection anyway.
40
+ *
41
+ * This is what lets a preview render a relation column that arrived as a bare
42
+ * foreign key: the id says *which* row, the declared target says *which
43
+ * collection*, and `RelationPreview` fetches the rest. Without it a scalar id
44
+ * is indistinguishable from a value of the wrong type.
45
+ */
46
+ export declare function getRelationTargetPath(property: RelationProperty): string | undefined;
30
47
  export declare function getTableName(collection: CollectionConfig): string;
31
48
  export declare function getTableVarName(tableName: string): string;
32
49
  export declare function getEnumVarName(tableName: string, propName: string): string;
@@ -92,6 +92,37 @@ export declare function resolveEnumValues(input: EnumValues): EnumValueConfig[]
92
92
  * 3. many-relations on an engine that has relations (SQL).
93
93
  */
94
94
  export declare function getEntityChildViews<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): EntityChildView[];
95
+ /**
96
+ * Each of `collection`'s tabs paired with the property that declared it, when a
97
+ * property declared it: child view key → property key.
98
+ *
99
+ * A many-relation can only be declared as a property — that is the documented
100
+ * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
101
+ * one declaration reaches the panel twice, and neither surface knew about the
102
+ * other. The form rendered a relation picker beside the tab, and the collection
103
+ * table rendered *two* columns under one heading: the relation's own column,
104
+ * showing the child rows, and a jump-to-tab button carrying the same name.
105
+ *
106
+ * The pairing is what lets each surface decide which half is redundant, and it
107
+ * has to be a pairing rather than two sets because the two keys differ whenever
108
+ * a relation is named. The match is on the resolved `relationName` — the
109
+ * identity `getEntityChildViews` itself dedupes on — so a relation declared in
110
+ * `relations` and pointed at by a differently-named property is recognised too.
111
+ *
112
+ * A relation with no property of its own is absent here, which is the point: it
113
+ * has exactly one surface already, and nothing to weigh it against.
114
+ *
115
+ * Only top-level properties: a relation nested inside a `map` gets no tab.
116
+ */
117
+ export declare function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): Map<string, string>;
118
+ /**
119
+ * The property keys of `collection` whose relation is already one of its tabs.
120
+ *
121
+ * What a form asks: the tab is the treatment for a list of child rows, so the
122
+ * picker beside it is the redundant half. See
123
+ * {@link getChildViewDeclaringProperties}.
124
+ */
125
+ export declare function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): Set<string>;
95
126
  /**
96
127
  * The child views of `collection` as bare collections.
97
128
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.13.1-canary.g249daa1",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "fast-equals": "6.0.2",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/types": "0.13.0",
44
- "@rebasepro/utils": "0.13.0"
43
+ "@rebasepro/utils": "0.13.1-canary.g249daa1",
44
+ "@rebasepro/types": "0.13.1-canary.g249daa1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
@@ -14,11 +14,13 @@ import {
14
14
  SDKCollectionClient,
15
15
  SDKQueryBuilderInterface,
16
16
  WhereFilterOp,
17
- WhereValue
17
+ WhereValue,
18
+ type ComputedSortField,
19
+ type SearchMatch
18
20
  } from "@rebasepro/types";
19
21
  import { toSnakeCase } from "@rebasepro/utils";
20
22
  import { QueryBuilder } from "./query_builder";
21
- import { collectAllPages, paginateFind } from "./paginate";
23
+ import { collectAllPages, paginateFind, resolveFindWindow } from "./paginate";
22
24
  import { deserializeFilter } from "./filter-dialect";
23
25
  import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
24
26
 
@@ -89,12 +91,19 @@ function rowToEntity<M extends Record<string, unknown>>(
89
91
  slug: string,
90
92
  primaryKeys: PrimaryKeyInfo[] = []
91
93
  ): Entity<M> {
94
+ // Query-computed metadata rides in on the row because that is how the wire
95
+ // carries it, but it is not a column: it belongs beside `values`, not in
96
+ // them. Left inside, `_matches` would show up in the record inspector as a
97
+ // field the collection never declared.
98
+ const { _matches, ...values } = row as Record<string, unknown> & { _matches?: SearchMatch[] };
99
+
92
100
  return {
93
101
  id: primaryKeys.length > 0
94
102
  ? buildCompositeId(row, primaryKeys)
95
103
  : row.id as string | number,
96
104
  path: slug,
97
- values: row as EntityValues<M>
105
+ values: values as EntityValues<M>,
106
+ ...(_matches ? { searchMatches: _matches } : {})
98
107
  };
99
108
  }
100
109
 
@@ -153,8 +162,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
153
162
  async find(params?: FindParams<M>): Promise<FindResponse<M>> {
154
163
  // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
155
164
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
156
- const limit = params?.limit ?? 20;
157
- const offset = params?.offset ?? 0;
165
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
158
166
 
159
167
  // One relation shape, whatever the call looks like.
160
168
  //
@@ -177,8 +185,12 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
177
185
  slug,
178
186
  {
179
187
  filter,
180
- limit: params?.limit,
181
- offset: params?.offset,
188
+ // Without this the group was dropped and the read ran
189
+ // unfiltered — every row the caller's policies allow,
190
+ // in place of the ones they asked for.
191
+ logical: params?.logical,
192
+ limit,
193
+ offset: driverOffset,
182
194
  orderBy: params?.orderBy?.[0],
183
195
  order: params?.orderBy?.[1],
184
196
  searchString: params?.searchString
@@ -187,9 +199,10 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
187
199
  )
188
200
  : await driver.fetchCollection<M>({
189
201
  path: slug,
190
- limit: params?.limit,
191
- offset: params?.offset,
202
+ limit,
203
+ offset: driverOffset,
192
204
  filter,
205
+ logical: params?.logical,
193
206
  orderBy: params?.orderBy?.[0],
194
207
  order: params?.orderBy?.[1],
195
208
  searchString: params?.searchString
@@ -199,7 +212,16 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
199
212
  let total = rows.length + offset;
200
213
  let hasMore = rows.length >= limit;
201
214
  if (driver.count) {
202
- total = await driver.count({ path: slug, filter });
215
+ // The same narrowing the rows were read with. Counting only by
216
+ // `filter` reported the whole collection beside a narrowed
217
+ // page, and `hasMore` is derived from it — so the list offered
218
+ // a next page that did not exist.
219
+ total = await driver.count({
220
+ path: slug,
221
+ filter,
222
+ logical: params?.logical,
223
+ searchString: params?.searchString
224
+ });
203
225
  hasMore = offset + rows.length < total;
204
226
  }
205
227
 
@@ -258,37 +280,70 @@ values: {} as Record<string, unknown> }
258
280
  });
259
281
  },
260
282
 
283
+ // Present only when the driver is: exposing these unconditionally and
284
+ // looping single writes underneath would give a caller neither the
285
+ // atomicity nor the single round trip they reached for a batch to get,
286
+ // while looking exactly like it had.
287
+ updateMany: driver.updateMany
288
+ ? async (updates: { id: string | number; data: Partial<EntityValues<M>> }[]): Promise<Entity<M>[]> => {
289
+ const rows = await driver.updateMany!<M>({
290
+ path: slug,
291
+ updates: updates.map(u => ({ id: u.id,
292
+ values: u.data })),
293
+ });
294
+ return rows.map(row => rowToEntity<M>(row, slug, getPks()));
295
+ }
296
+ : undefined,
297
+
298
+ deleteMany: driver.deleteMany
299
+ ? async (ids: (string | number)[]): Promise<void> => {
300
+ await driver.deleteMany!<M>({ path: slug,
301
+ ids });
302
+ }
303
+ : undefined,
304
+
261
305
  count: driver.count
262
306
  ? async (params?: FindParams<M>): Promise<number> => {
263
307
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
308
+ // Every narrowing `find()` applies has to apply here too, or
309
+ // the count describes a different query than the one it is
310
+ // reported against.
264
311
  return driver.count!({
265
312
  path: slug,
266
- filter
313
+ filter,
314
+ logical: params?.logical,
315
+ searchString: params?.searchString
267
316
  });
268
317
  }
269
318
  : undefined,
270
319
 
271
320
  listen: driver.listenCollection
272
321
  ? (params: FindParams<M> | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
273
- const limit = params?.limit ?? 20;
274
- const offset = params?.offset ?? 0;
322
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
275
323
  // Realtime has no REST-pipeline equivalent, so the rows arrive
276
324
  // admin-shaped. Flatten them to the one shape the rest of this
277
325
  // accessor serves.
278
326
  const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
279
327
  return driver.listenCollection!<M>({
280
328
  path: slug,
281
- limit: params?.limit,
282
- offset: params?.offset,
329
+ limit,
330
+ offset: driverOffset,
283
331
  filter: params?.where,
332
+ logical: params?.logical,
284
333
  orderBy: params?.orderBy?.[0],
285
334
  order: params?.orderBy?.[1],
286
335
  searchString: params?.searchString,
336
+ searchExplain: params?.searchExplain,
287
337
  onUpdate: (entities) => {
288
338
  onUpdate({
289
339
  data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
290
340
  meta: {
291
- total: entities.length,
341
+ // No count is issued on this path, so the total
342
+ // is unknown; the lower bound is the rows in
343
+ // hand plus the ones paged past to reach them.
344
+ // Reporting `entities.length` claimed a read at
345
+ // offset 100 had found a collection of two.
346
+ total: offset + entities.length,
292
347
  limit,
293
348
  offset,
294
349
  hasMore: entities.length >= limit
@@ -318,7 +373,7 @@ values: {} as Record<string, unknown> }
318
373
  }
319
374
  return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValue<M[keyof M & string]>);
320
375
  },
321
- orderBy(column: keyof M & string, ascending?: "asc" | "desc") {
376
+ orderBy(column: (keyof M & string) | ComputedSortField, ascending?: "asc" | "desc") {
322
377
  return new QueryBuilder<M>(accessor).orderBy(column, ascending);
323
378
  },
324
379
  limit(count: number) {
@@ -327,8 +382,15 @@ values: {} as Record<string, unknown> }
327
382
  offset(count: number) {
328
383
  return new QueryBuilder<M>(accessor).offset(count);
329
384
  },
330
- search(searchString: string) {
331
- return new QueryBuilder<M>(accessor).search(searchString);
385
+ search(searchString: string, options?: { explain?: boolean }) {
386
+ return new QueryBuilder<M>(accessor).search(searchString, options);
387
+ },
388
+ vectorSearch(
389
+ property: string,
390
+ vector: number[],
391
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
392
+ ) {
393
+ return new QueryBuilder<M>(accessor).vectorSearch(property, vector, options);
332
394
  },
333
395
  include(...relations: string[]) {
334
396
  return new QueryBuilder<M>(accessor).include(...relations);
@@ -432,14 +494,27 @@ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown
432
494
  return this;
433
495
  }
434
496
 
435
- orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
497
+ orderBy(column: (keyof M & string) | ComputedSortField, direction: "asc" | "desc" = "asc"): this {
436
498
  this.params.orderBy = [column, direction];
437
499
  return this;
438
500
  }
439
501
 
440
502
  limit(count: number): this { this.params.limit = count; return this; }
441
503
  offset(count: number): this { this.params.offset = count; return this; }
442
- search(searchString: string): this { this.params.searchString = searchString; return this; }
504
+ search(searchString: string, options?: { explain?: boolean }): this { this.params.searchString = searchString; if (options?.explain !== undefined) this.params.searchExplain = options.explain; return this; }
505
+ vectorSearch(
506
+ property: string,
507
+ vector: number[],
508
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
509
+ ): this {
510
+ this.params.vectorSearch = {
511
+ property,
512
+ vector,
513
+ ...(options?.distance !== undefined && { distance: options.distance }),
514
+ ...(options?.threshold !== undefined && { threshold: options.threshold })
515
+ };
516
+ return this;
517
+ }
443
518
  include(...relations: string[]): this { this.params.include = relations; return this; }
444
519
 
445
520
  async find(): Promise<FindResult<M>> {
@@ -506,9 +581,39 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
506
581
  async update(id: string | number, data: Partial<M>): Promise<M> {
507
582
  return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
508
583
  },
584
+ async updateMany(updates: { id: string | number; data: Partial<M> }[]): Promise<M[]> {
585
+ if (!Array.isArray(updates)) {
586
+ throw new TypeError("updateMany expects an array of { id, data } entries.");
587
+ }
588
+ if (updates.length === 0) return [];
589
+ if (!snap.updateMany) {
590
+ throw new Error(
591
+ "Bulk updates are not supported by this collection's data source. " +
592
+ "Fall back to update() per record."
593
+ );
594
+ }
595
+ const rows = await snap.updateMany(
596
+ updates.map(u => ({ id: u.id,
597
+ data: u.data as Partial<EntityValues<M>> }))
598
+ );
599
+ return rows.map(entityToRow);
600
+ },
509
601
  delete(id: string | number): Promise<void> {
510
602
  return snap.delete(id);
511
603
  },
604
+ async deleteMany(ids: (string | number)[]): Promise<void> {
605
+ if (!Array.isArray(ids)) {
606
+ throw new TypeError("deleteMany expects an array of ids.");
607
+ }
608
+ if (ids.length === 0) return;
609
+ if (!snap.deleteMany) {
610
+ throw new Error(
611
+ "Bulk deletes are not supported by this collection's data source. " +
612
+ "Fall back to delete() per record."
613
+ );
614
+ }
615
+ await snap.deleteMany(ids);
616
+ },
512
617
  count: snap.count ? (params?: FindParams<M>) => snap.count!(params) : undefined,
513
618
  listen: snap.listen
514
619
  ? (params: FindParams<M> | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
@@ -529,6 +634,11 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
529
634
  limit: (count: number) => new SdkQueryBuilder<M>(client).limit(count),
530
635
  offset: (count: number) => new SdkQueryBuilder<M>(client).offset(count),
531
636
  search: (searchString: string) => new SdkQueryBuilder<M>(client).search(searchString),
637
+ vectorSearch: (
638
+ property: string,
639
+ vector: number[],
640
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
641
+ ) => new SdkQueryBuilder<M>(client).vectorSearch(property, vector, options),
532
642
  include: (...relations: string[]) => new SdkQueryBuilder<M>(client).include(...relations)
533
643
  };
534
644
  return client;
@@ -537,7 +647,7 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
537
647
  /**
538
648
  * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
539
649
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
540
- * `{ id, path, values }` view-model the admin admin renders.
650
+ * `{ id, path, values }` view-model the admin panel renders.
541
651
  */
542
652
  function toEntityAccessor<M extends Record<string, unknown>>(
543
653
  sdk: SDKCollectionClient<M>,
@@ -584,6 +694,11 @@ function toEntityAccessor<M extends Record<string, unknown>>(
584
694
  limit: (count: number) => new QueryBuilder<M>(accessor).limit(count),
585
695
  offset: (count: number) => new QueryBuilder<M>(accessor).offset(count),
586
696
  search: (searchString: string) => new QueryBuilder<M>(accessor).search(searchString),
697
+ vectorSearch: (
698
+ property: string,
699
+ vector: number[],
700
+ options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
701
+ ) => new QueryBuilder<M>(accessor).vectorSearch(property, vector, options),
587
702
  include: (...relations: string[]) => new QueryBuilder<M>(accessor).include(...relations)
588
703
  };
589
704
  return accessor;
@@ -598,7 +713,16 @@ function toEntityAccessor<M extends Record<string, unknown>>(
598
713
  * admin `RebaseDataContext` — without it the admin renders rows with only their
599
714
  * `id`.
600
715
  */
601
- export function wrapAsEntityData(sdkData: RebaseSdkData, options?: EntityDataOptions): RebaseData {
716
+ /**
717
+ * Only the by-slug accessor is asked for, so only that is required.
718
+ *
719
+ * Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
720
+ * dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
721
+ * it, because its own `collection` method is not a `SDKCollectionClient`. So a
722
+ * caller holding a *typed* client could not pass it to a function that reads
723
+ * one method off it, and that method is identical on every instantiation.
724
+ */
725
+ export function wrapAsEntityData(sdkData: Pick<RebaseSdkData, "collection">, options?: EntityDataOptions): RebaseData {
602
726
  const cache = new Map<string, CollectionAccessor>();
603
727
  const primaryKeysFor = createPrimaryKeyResolver(options);
604
728
 
@@ -354,9 +354,34 @@ export function serializeLogicalCondition(
354
354
  * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
355
355
  * // → { type: "or", conditions: [...] }
356
356
  */
357
+ /**
358
+ * How deeply `or(...)`/`and(...)` groups may nest.
359
+ *
360
+ * This parser recurses once per level, on a value that arrives in a query
361
+ * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
362
+ * stack size exceeded`, which a caller sees as a 500 about the call stack
363
+ * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
364
+ * below that in practice, but "the HTTP layer happens to stop it" is not a
365
+ * bound this parser should rely on.
366
+ *
367
+ * Thirty-two is far past anything a real filter expresses; the deepest in this
368
+ * repository's own tests is three.
369
+ */
370
+ export const MAX_LOGICAL_NESTING_DEPTH = 32;
371
+
357
372
  export function deserializeLogicalCondition(
358
- str: string
373
+ str: string,
374
+ // Not `depth`: the body already uses that name for paren tracking, inside a
375
+ // block that shadows a parameter of the same name — so the recursion
376
+ // counter silently became the paren counter and never grew.
377
+ nesting = 0
359
378
  ): LogicalCondition | FilterCondition {
379
+ if (nesting > MAX_LOGICAL_NESTING_DEPTH) {
380
+ throw new Error(
381
+ `Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +
382
+ "Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`."
383
+ );
384
+ }
360
385
  // Check for logical group: "and(...)" or "or(...)"
361
386
  const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
362
387
  if (logicalMatch) {
@@ -371,11 +396,11 @@ export function deserializeLogicalCondition(
371
396
  if (innerStr[i] === "(") depth++;
372
397
  else if (innerStr[i] === ")") depth--;
373
398
  else if (innerStr[i] === "," && depth === 0) {
374
- conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
399
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start, i), nesting + 1));
375
400
  start = i + 1;
376
401
  }
377
402
  }
378
- conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
403
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start), nesting + 1));
379
404
 
380
405
  return { type, conditions };
381
406
  }