@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.58368ce

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.
@@ -2,6 +2,15 @@ import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
2
  import { CollectionConfig, Property } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
4
  import { getTableName } from "@rebasepro/common";
5
+ import { logger } from "@rebasepro/server";
6
+
7
+ // Row identity is derived on both sides of the wire — the driver parses an
8
+ // incoming address into key columns, the admin derives one from a served row —
9
+ // so the implementation lives in `common` and both agree by construction.
10
+ export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
11
+ export type { PrimaryKeyInfo } from "@rebasepro/common";
12
+ import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
13
+ import type { PrimaryKeyInfo } from "@rebasepro/common";
5
14
 
6
15
  /**
7
16
  * Shared helper functions for row operations.
@@ -49,7 +58,7 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
49
58
  return table;
50
59
  }
51
60
 
52
- export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] {
61
+ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
53
62
  const table = getTableForCollection(collection, registry);
54
63
 
55
64
  // Fallback to explicitly defined isId properties
@@ -68,7 +77,7 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
68
77
  }
69
78
 
70
79
  // Otherwise infer from Drizzle schema
71
- const keys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[] = [];
80
+ const keys: PrimaryKeyInfo[] = [];
72
81
  for (const [key, colRaw] of Object.entries(table)) {
73
82
  const col = colRaw as AnyPgColumn;
74
83
  if (col && typeof col === "object" && "primary" in col && col.primary) {
@@ -96,56 +105,132 @@ isUUID });
96
105
  return keys;
97
106
  }
98
107
 
99
- export function parseIdValues(idValue: string | number, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): Record<string, string | number> {
100
- const result: Record<string, string | number> = {};
108
+ /**
109
+ * Collections whose key the *browser* cannot resolve, and what it will do
110
+ * instead.
111
+ *
112
+ * The two sides resolve keys from different evidence. This driver reads, in
113
+ * order: properties marked `isId`, the primary keys of the Drizzle schema, then
114
+ * a column literally named `id`. The admin shares the `CollectionConfig` — it
115
+ * compiles the same collection files into its bundle — but never the Drizzle
116
+ * schema, so the middle tier is invisible to it.
117
+ *
118
+ * Nothing can normalize this at runtime: the server does not serve the admin
119
+ * its collections, so a key resolved here cannot be handed over there. The
120
+ * config files are the only thing both sides read, so the fix is an edit to
121
+ * them, and the most this can do is say exactly which edit.
122
+ *
123
+ * Two shapes, and the second is the dangerous one:
124
+ *
125
+ * - No `isId`, no `id` property → the admin resolves no address, warns in the
126
+ * console, and rows cannot be opened or linked.
127
+ * - No `isId`, but an `id` property that is *not* the key → the admin addresses
128
+ * rows by `id` while this driver reads the address as the real key. Nothing
129
+ * errors: the addresses look right and route wrong.
130
+ */
131
+ export function findUnresolvableKeyCollections(
132
+ collections: CollectionConfig[],
133
+ registry: PostgresCollectionRegistry
134
+ ): { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] {
135
+ const findings: { collection: CollectionConfig; keys: PrimaryKeyInfo[]; shadowedByIdProperty: boolean }[] = [];
136
+
137
+ for (const collection of collections) {
138
+ // Declared `isId` is the tier both sides share: if it is there, they agree.
139
+ if (getDeclaredPrimaryKeys(collection).length > 0) continue;
140
+
141
+ let keys: PrimaryKeyInfo[];
142
+ try {
143
+ keys = getPrimaryKeys(collection, registry);
144
+ } catch {
145
+ // No registered table — nothing resolved here either, so there is no
146
+ // disagreement to report.
147
+ continue;
148
+ }
149
+ if (keys.length === 0) continue;
101
150
 
102
- if (primaryKeys.length === 0) {
103
- return result;
104
- }
151
+ // A single key named `id` is the last tier on both sides: they agree
152
+ // without anything being declared.
153
+ if (keys.length === 1 && keys[0].fieldName === "id") continue;
105
154
 
106
- if (primaryKeys.length === 1) {
107
- const pk = primaryKeys[0];
108
- if (pk.type === "number" && !pk.isUUID) {
109
- const parsed = typeof idValue === "number" ? idValue : parseInt(String(idValue), 10);
110
- if (isNaN(parsed)) {
111
- throw new Error(`Invalid numeric ID: ${idValue}`);
112
- }
113
- result[pk.fieldName] = parsed;
114
- } else {
115
- result[pk.fieldName] = String(idValue);
116
- }
117
- return result;
155
+ findings.push({
156
+ collection,
157
+ keys,
158
+ shadowedByIdProperty: Boolean(collection.properties?.id)
159
+ });
118
160
  }
119
161
 
120
- // Composite key - split by :::
121
- const parts = String(idValue).split(":::");
122
- if (parts.length !== primaryKeys.length) {
123
- throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);
124
- }
162
+ return findings;
163
+ }
125
164
 
126
- for (let i = 0; i < primaryKeys.length; i++) {
127
- const pk = primaryKeys[i];
128
- const val = parts[i];
129
- if (pk.type === "number" && !pk.isUUID) {
130
- const parsed = parseInt(val, 10);
131
- if (isNaN(parsed)) {
132
- throw new Error(`Invalid numeric ID component: ${val}`);
133
- }
134
- result[pk.fieldName] = parsed;
135
- } else {
136
- result[pk.fieldName] = val;
137
- }
165
+ /**
166
+ * Report the collections from {@link findUnresolvableKeyCollections} at boot,
167
+ * with the edit that fixes each one.
168
+ *
169
+ * Grouped by failure, not by collection: the shadowed case is a routing bug and
170
+ * the silent case is a missing feature, and they deserve different urgency.
171
+ */
172
+ export function warnOnKeysTheAdminCannotResolve(
173
+ collections: CollectionConfig[],
174
+ registry: PostgresCollectionRegistry
175
+ ): void {
176
+ const findings = findUnresolvableKeyCollections(collections, registry);
177
+ if (findings.length === 0) return;
178
+
179
+ const edit = (f: { collection: CollectionConfig; keys: PrimaryKeyInfo[] }) =>
180
+ `${f.collection.slug}: mark ${f.keys.map(k => `\`${k.fieldName}\``).join(" and ")} with ` +
181
+ `\`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
182
+
183
+ const shadowed = findings.filter(f => f.shadowedByIdProperty);
184
+ const silent = findings.filter(f => !f.shadowedByIdProperty);
185
+
186
+ if (shadowed.length > 0) {
187
+ logger.warn(
188
+ `⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema — but they ` +
189
+ `do have a property called \`id\`. The admin has no way to know \`id\` is not the key, so it will ` +
190
+ `address rows by it while this server reads the address as the real key: the links look right and ` +
191
+ `route wrong. Nothing will error.\n\n` +
192
+ shadowed.map(f => ` • ${edit(f)}`).join("\n") + "\n"
193
+ );
138
194
  }
139
195
 
140
- return result;
196
+ if (silent.length > 0) {
197
+ logger.warn(
198
+ `⚠️ These collections declare no \`isId\`, and their key is only in the drizzle schema, which the ` +
199
+ `admin never sees. It will resolve no address for their rows, so detail links, caching and ` +
200
+ `relations will not work for them:\n\n` +
201
+ silent.map(f => ` • ${edit(f)}`).join("\n") + "\n"
202
+ );
203
+ }
141
204
  }
142
205
 
143
- export function buildCompositeId(values: Record<string, unknown>, primaryKeys: { fieldName: string; type: "string" | "number"; isUUID?: boolean }[]): string {
144
- if (primaryKeys.length === 0) {
145
- return "";
146
- }
147
- if (primaryKeys.length === 1) {
148
- return String(values[primaryKeys[0].fieldName] ?? "");
206
+ /**
207
+ * The address of a row: derived from the collection's primary keys, because a
208
+ * row does not carry one — it is exactly its columns.
209
+ *
210
+ * Falls back to a literal `id` column, which covers the two cases where the
211
+ * keys cannot be resolved: a row that reached us from somewhere other than the
212
+ * postgres driver, and a collection whose table the registry cannot look up
213
+ * (`getPrimaryKeys` throws for an unregistered table rather than returning
214
+ * nothing). Returns `""` when there is no key and no `id` — callers decide what
215
+ * that means, since "unaddressable" is a different answer in a notification
216
+ * (broadcast a wildcard) than in a save (fail).
217
+ */
218
+ export function deriveRowAddress(
219
+ row: Record<string, unknown>,
220
+ collection: CollectionConfig,
221
+ registry: PostgresCollectionRegistry
222
+ ): string {
223
+ try {
224
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
225
+ // An all-empty composite is indistinguishable from a missing key, and
226
+ // `buildCompositeId` returns "" for no keys at all.
227
+ if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
228
+ return composite;
229
+ }
230
+ } catch {
231
+ /* unresolvable table or keys — fall through to the literal column */
149
232
  }
150
- return primaryKeys.map(pk => String(values[pk.fieldName] ?? "")).join(":::");
233
+ if (row.id !== undefined && row.id !== null) return String(row.id);
234
+ return "";
151
235
  }
236
+
@@ -154,9 +154,10 @@ export class DataService implements DataRepository {
154
154
  collectionPath: string,
155
155
  values: Partial<M>,
156
156
  id?: string | number,
157
- databaseId?: string
157
+ databaseId?: string,
158
+ options?: { upsert?: boolean }
158
159
  ): Promise<Record<string, unknown>> {
159
- return this.persistService.save<M>(collectionPath, values, id, databaseId);
160
+ return this.persistService.save<M>(collectionPath, values, id, databaseId, options);
160
161
  }
161
162
 
162
163
  /**
@@ -8,6 +8,7 @@ export {
8
8
  getCollectionByPath,
9
9
  getTableForCollection,
10
10
  getPrimaryKeys,
11
+ deriveRowAddress,
11
12
  parseIdValues,
12
13
  buildCompositeId
13
14
  } from "./collection-helpers";
@@ -14,7 +14,7 @@ import { applyAuthContext } from "../security/rls-enforcement";
14
14
  import { logger } from "@rebasepro/server";
15
15
  import { sanitizeErrorForClient } from "../utils/pg-error-utils";
16
16
  import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
17
- import { getPrimaryKeys, buildCompositeId } from "./collection-helpers";
17
+ import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
18
18
 
19
19
  /** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
20
20
  const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
@@ -390,7 +390,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
390
390
  authContext
391
391
  );
392
392
 
393
- this.sendCollectionUpdate(clientId, subscriptionId, rows);
393
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
394
394
 
395
395
  } catch (error) {
396
396
  const sanitized = sanitizeErrorForClient(error, request.path);
@@ -554,7 +554,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
554
554
  // Phase 1: Send instant row-level patch (no DB query)
555
555
  // This gives immediate cross-tab feedback
556
556
  if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
557
- this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
557
+ this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
558
558
  }
559
559
 
560
560
  // Phase 2: Schedule a deferred full refetch for correctness
@@ -609,7 +609,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
609
609
  if (!this._subscriptions.has(subscriptionId)) return;
610
610
  try {
611
611
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
612
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
612
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
613
613
  } catch (error) {
614
614
  const sanitized = sanitizeErrorForClient(error, notifyPath);
615
615
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -910,11 +910,12 @@ roles: activeAuth.roles },
910
910
  return await this.dataService.fetchOne(notifyPath, id);
911
911
  }
912
912
 
913
- private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[]) {
913
+ private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[], path: string) {
914
914
  const message: CollectionUpdateMessage = {
915
915
  type: "collection_update",
916
916
  subscriptionId,
917
- rows: rows
917
+ rows: rows,
918
+ pks: this.primaryKeysForPath(path)
918
919
  };
919
920
  this.sendMessage(clientId, message);
920
921
  }
@@ -931,17 +932,43 @@ roles: activeAuth.roles },
931
932
  /**
932
933
  * Send a lightweight row-level patch to a collection subscriber.
933
934
  * The client can merge this into its cached data for instant feedback.
935
+ *
936
+ * The key columns ride along: the patch names a row by address, and the
937
+ * client has to find that row among the ones it cached — which carry
938
+ * columns and no address. The SDK holds no collection config to derive one
939
+ * from, so this is the only place the mapping can come from.
934
940
  */
935
- private sendCollectionPatch(clientId: string, subscriptionId: string, id: string, row: Record<string, unknown> | null) {
941
+ private sendCollectionPatch(
942
+ clientId: string,
943
+ subscriptionId: string,
944
+ id: string,
945
+ row: Record<string, unknown> | null,
946
+ notifyPath: string
947
+ ) {
936
948
  const message: CollectionPatchMessage = {
937
949
  type: "collection_patch",
938
950
  subscriptionId,
939
951
  id,
940
- row: row
952
+ row: row,
953
+ pks: this.primaryKeysForPath(notifyPath)
941
954
  };
942
955
  this.sendMessage(clientId, message);
943
956
  }
944
957
 
958
+ /** The key columns of the collection at `path`, if they can be resolved. */
959
+ private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
960
+ try {
961
+ const collection = this.registry.getCollectionByPath(path);
962
+ if (!collection) return undefined;
963
+ const keys = getPrimaryKeys(collection, this.registry);
964
+ return keys.length > 0 ? keys : undefined;
965
+ } catch {
966
+ // Unregistered table, or a relation path with no collection of its
967
+ // own: the client keeps its pre-existing `id` behaviour.
968
+ return undefined;
969
+ }
970
+ }
971
+
945
972
  private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
946
973
  const message = {
947
974
  type: "error" as const,
@@ -1294,17 +1321,9 @@ lastSeen: Date.now() });
1294
1321
 
1295
1322
  /** Compute the canonical (possibly composite) id string from a captured row. */
1296
1323
  private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
1297
- try {
1298
- const primaryKeys = getPrimaryKeys(collection, this.registry);
1299
- const composite = buildCompositeId(row, primaryKeys);
1300
- if (composite && composite !== ":::") return composite;
1301
- } catch {
1302
- /* fall through to id-field / wildcard */
1303
- }
1304
- // Fallbacks: a plain `id` column (common), else a collection-level
1305
- // invalidation ("*") — single-row subs won't match but collection subs refetch.
1306
- if (row.id !== undefined && row.id !== null) return String(row.id);
1307
- return "*";
1324
+ // Unaddressable falls back to a collection-level invalidation: single-row
1325
+ // subs won't match, but collection subs still refetch.
1326
+ return deriveRowAddress(row, collection, this.registry) || "*";
1308
1327
  }
1309
1328
 
1310
1329
  // ── App/CDC de-duplication ──
package/src/websocket.ts CHANGED
@@ -620,9 +620,12 @@ roles: ["anon"] };
620
620
  if (error instanceof Error) {
621
621
  logger.error("Stack trace", { detail: error.stack });
622
622
  }
623
+ // Unwrap the cause chain: a Drizzle failure reports itself as
624
+ // "Failed query: <sql> params:", which tells the user nothing and
625
+ // echoes the statement back at them. The reason is in the cause.
623
626
  const errorMessage = process.env.NODE_ENV === "production"
624
627
  ? "An unexpected error occurred"
625
- : (error instanceof Error ? error.message : "An unexpected error occurred");
628
+ : extractErrorMessage(error);
626
629
  const errorResponse = {
627
630
  type: "ERROR",
628
631
  requestId,
@@ -1,10 +0,0 @@
1
- import { CollectionConfig, SecurityRule } from "@rebasepro/types";
2
- /**
3
- * Returns the security rules that should be applied to a collection: the
4
- * author's explicit `securityRules` plus the framework defaults described in
5
- * the module doc (baseline server/admin read for all collections; self-read
6
- * and the admin write gate for auth collections).
7
- *
8
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
9
- */
10
- export declare function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[];
@@ -1,132 +0,0 @@
1
- import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
- import { getTableName } from "@rebasepro/common";
3
-
4
- /**
5
- * Default RLS policies injected by the schema generator.
6
- *
7
- * Rebase's enforcement model is unified: authenticated (user-context) requests
8
- * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
9
- * statement — reads and writes. A collection's `securityRules` are the whole
10
- * authorization model. The server context (auth flows, migrations,
11
- * `dataAsAdmin`) runs as the owner and bypasses RLS.
12
- *
13
- * Because RLS default-denies, every collection is **locked by default**: with
14
- * no rules, only the server context and admins can touch it. The generator
15
- * injects that safe baseline:
16
- *
17
- * **For every collection**
18
- * 1. A permissive **server-or-admin SELECT** grant.
19
- * 2. A permissive **server-or-admin write** grant (insert/update/delete).
20
- *
21
- * Author `securityRules` are permissive and OR together, so explicit rules only
22
- * *broaden* access from this locked baseline (e.g. "users read/write their own
23
- * rows").
24
- *
25
- * **For auth collections additionally**
26
- * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
27
- * their own row (profile, session bootstrap) without every app re-declaring
28
- * it.
29
- * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
30
- * every other policy, so a write is rejected unless the caller is an admin
31
- * (or the server context) — even if the author also wrote a permissive rule
32
- * such as "a user may edit their own row". Without this, a permissive owner
33
- * rule would let a user change their own `roles`.
34
- *
35
- * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
36
- * — the built-in flows that run without a user (signup, migrations) set no user
37
- * GUC — which also lets the owner connection satisfy these policies even under
38
- * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
39
- * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
40
- *
41
- * Opt out with `disableDefaultPolicies: true` to take full responsibility for
42
- * the collection's RLS.
43
- */
44
- // Expressed structurally (not as raw SQL) so the admin UI can evaluate it
45
- // exactly — the framework's most security-critical policies must be reflected
46
- // precisely, not left as un-evaluable raw clauses. Compiles to
47
- // `auth.uid() IS NULL OR (string_to_array(auth.roles(), ',') && ARRAY['admin'])`.
48
- //
49
- // `serverContext()`, emphatically not `not(authenticated())`: the server arm of
50
- // this grant must match the server context and nothing else. Anonymous visitors
51
- // are not signed in either, so a negated `authenticated()` would hand them the
52
- // server-or-admin grant on every collection's default policy.
53
- const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
54
- policy.serverContext(),
55
- policy.rolesOverlap(["admin"])
56
- );
57
-
58
- /** Write operations that must be admin-gated by default on auth collections. */
59
- const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
60
-
61
- /** Whether a collection is flagged as an authentication collection. */
62
- function isAuthCollection(collection: CollectionConfig): boolean {
63
- const auth = collection.auth;
64
- return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
65
- }
66
-
67
- /** The property marked as the row id (falls back to `id`). */
68
- function getIdPropertyName(collection: CollectionConfig): string {
69
- for (const [name, prop] of Object.entries(collection.properties ?? {})) {
70
- if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
71
- return name;
72
- }
73
- }
74
- return "id";
75
- }
76
-
77
- /**
78
- * Returns the security rules that should be applied to a collection: the
79
- * author's explicit `securityRules` plus the framework defaults described in
80
- * the module doc (baseline server/admin read for all collections; self-read
81
- * and the admin write gate for auth collections).
82
- *
83
- * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
84
- */
85
- export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
86
- const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
87
-
88
- if (collection.disableDefaultPolicies) {
89
- return explicit;
90
- }
91
-
92
- const tableName = getTableName(collection);
93
- const injected: SecurityRule[] = [];
94
-
95
- // Baseline read + write: the server context and admins can always operate.
96
- // RLS default-denies under the user role, so without these a rule-less
97
- // collection would be locked to everyone — including the admin studio.
98
- // Author rules are permissive and broaden access from here.
99
- injected.push({
100
- name: `${tableName}_default_admin_read`,
101
- operations: ["select"],
102
- condition: SERVER_OR_ADMIN_EXPR
103
- });
104
- injected.push({
105
- name: `${tableName}_default_admin_write`,
106
- operations: [...DEFAULT_GUARDED_OPS],
107
- condition: SERVER_OR_ADMIN_EXPR,
108
- check: SERVER_OR_ADMIN_EXPR
109
- });
110
-
111
- if (isAuthCollection(collection)) {
112
- // Self-read: a user can always read their own row.
113
- injected.push({
114
- name: `${tableName}_default_self_read`,
115
- operations: ["select"],
116
- condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
117
- });
118
-
119
- // Restrictive gate: AND'd with all other policies, so no permissive rule
120
- // (e.g. an owner "edit your own row" rule) can let a non-admin change
121
- // privileged columns like `roles`.
122
- injected.push({
123
- name: `${tableName}_require_admin_write`,
124
- mode: "restrictive",
125
- operations: [...DEFAULT_GUARDED_OPS],
126
- condition: SERVER_OR_ADMIN_EXPR,
127
- check: SERVER_OR_ADMIN_EXPR
128
- });
129
- }
130
-
131
- return [...explicit, ...injected];
132
- }