@sequenceholdings/orm 2.8.3 → 2.8.5

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.
Files changed (52) hide show
  1. package/dist/authoring.cjs +154 -45
  2. package/dist/authoring.cjs.map +1 -1
  3. package/dist/authoring.d.cts +12 -2
  4. package/dist/authoring.d.ts +12 -2
  5. package/dist/authoring.js +154 -45
  6. package/dist/authoring.js.map +1 -1
  7. package/dist/canonical.cjs +64 -14
  8. package/dist/canonical.cjs.map +1 -1
  9. package/dist/canonical.d.cts +12 -7
  10. package/dist/canonical.d.ts +12 -7
  11. package/dist/canonical.js +64 -14
  12. package/dist/canonical.js.map +1 -1
  13. package/dist/cli.cjs +78 -20
  14. package/dist/cli.cjs.map +1 -1
  15. package/dist/cli.js +78 -20
  16. package/dist/cli.js.map +1 -1
  17. package/dist/compiler.cjs +77 -20
  18. package/dist/compiler.cjs.map +1 -1
  19. package/dist/compiler.d.cts +4 -4
  20. package/dist/compiler.d.ts +4 -4
  21. package/dist/compiler.js +77 -20
  22. package/dist/compiler.js.map +1 -1
  23. package/dist/index.cjs +161 -47
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +11 -11
  26. package/dist/index.d.ts +11 -11
  27. package/dist/index.js +161 -47
  28. package/dist/index.js.map +1 -1
  29. package/dist/ir.cjs +71 -16
  30. package/dist/ir.cjs.map +1 -1
  31. package/dist/ir.d.cts +9 -9
  32. package/dist/ir.d.ts +9 -9
  33. package/dist/ir.js +71 -16
  34. package/dist/ir.js.map +1 -1
  35. package/dist/{naming-CU9ThP_E.d.cts → naming-BMZKFO0W.d.cts} +1 -2
  36. package/dist/{naming-CPfx7EfF.d.ts → naming-DUk7AcXs.d.ts} +1 -2
  37. package/dist/predicate-v2.cjs +71 -16
  38. package/dist/predicate-v2.cjs.map +1 -1
  39. package/dist/predicate-v2.d.cts +3 -3
  40. package/dist/predicate-v2.d.ts +3 -3
  41. package/dist/predicate-v2.js +71 -16
  42. package/dist/predicate-v2.js.map +1 -1
  43. package/dist/sdl.cjs +71 -16
  44. package/dist/sdl.cjs.map +1 -1
  45. package/dist/sdl.d.cts +2 -2
  46. package/dist/sdl.d.ts +2 -2
  47. package/dist/sdl.js +71 -16
  48. package/dist/sdl.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/codegen-gql/apollo-hooks.cjs +1 -1
  51. package/src/codegen-gql/server-fns.cjs +1 -1
  52. package/templates/orm-namespace/graphql/example/queries.ts +1 -1
@@ -286,7 +286,7 @@ declare const nonNullFirst: (candidates: readonly string[]) => Comparator;
286
286
  * wins; `ord` is always appended as the final tie-break.
287
287
  *
288
288
  * ```ts
289
- * custom(['edits', 'abrigo'], "CASE WHEN source = 'abrigo' AND value IS NOT NULL THEN 0 ELSE 1 END")
289
+ * custom(['edits', 'vendor'], "CASE WHEN source = 'vendor' AND value IS NOT NULL THEN 0 ELSE 1 END")
290
290
  * ```
291
291
  */
292
292
  declare const custom: (candidates: readonly string[], orderBy: string) => Comparator;
@@ -305,6 +305,14 @@ interface ResolvedTableConfig {
305
305
  default?: Comparator;
306
306
  rows?: {
307
307
  deletedBy?: readonly string[];
308
+ /**
309
+ * A row exists only while this etl source has its key; edits and other
310
+ * sources only change values. A column resolved from this source alone
311
+ * keeps the synced column's NOT NULL. Exactly one etl source. A read-only
312
+ * table (no write policy) may then declare a composite key: several
313
+ * `.primaryKey()` columns that are exactly this source's primary key.
314
+ */
315
+ existsIn?: readonly string[];
308
316
  };
309
317
  links?: Record<string, SeqLink>;
310
318
  /** Reads use `read`; writes are evaluated against the resolved row. */
@@ -338,6 +346,8 @@ interface ResolvedViewConfig {
338
346
  orderBy?: string;
339
347
  }>;
340
348
  readonly deletedBy: readonly string[];
349
+ /** `rows.existsIn`: the etl source whose rows are the table's rows. */
350
+ readonly existsIn?: readonly string[];
341
351
  readonly policies: Omit<ResolvedTablePoliciesInput, 'read'>;
342
352
  }
343
353
  interface ResolvedSourcePlan {
@@ -547,7 +557,7 @@ interface ForeignKeyInput {
547
557
  interface TableConfig {
548
558
  displayName?: string;
549
559
  description?: string;
550
- /** Table-level migration rename hint (new in v2). */
560
+ /** Table-level migration rename hint. */
551
561
  renamedFrom?: string;
552
562
  /** Composite primary key: property names (or column names). Mutually exclusive with column-level `.primaryKey()`. */
553
563
  primaryKey?: readonly string[];
@@ -286,7 +286,7 @@ declare const nonNullFirst: (candidates: readonly string[]) => Comparator;
286
286
  * wins; `ord` is always appended as the final tie-break.
287
287
  *
288
288
  * ```ts
289
- * custom(['edits', 'abrigo'], "CASE WHEN source = 'abrigo' AND value IS NOT NULL THEN 0 ELSE 1 END")
289
+ * custom(['edits', 'vendor'], "CASE WHEN source = 'vendor' AND value IS NOT NULL THEN 0 ELSE 1 END")
290
290
  * ```
291
291
  */
292
292
  declare const custom: (candidates: readonly string[], orderBy: string) => Comparator;
@@ -305,6 +305,14 @@ interface ResolvedTableConfig {
305
305
  default?: Comparator;
306
306
  rows?: {
307
307
  deletedBy?: readonly string[];
308
+ /**
309
+ * A row exists only while this etl source has its key; edits and other
310
+ * sources only change values. A column resolved from this source alone
311
+ * keeps the synced column's NOT NULL. Exactly one etl source. A read-only
312
+ * table (no write policy) may then declare a composite key: several
313
+ * `.primaryKey()` columns that are exactly this source's primary key.
314
+ */
315
+ existsIn?: readonly string[];
308
316
  };
309
317
  links?: Record<string, SeqLink>;
310
318
  /** Reads use `read`; writes are evaluated against the resolved row. */
@@ -338,6 +346,8 @@ interface ResolvedViewConfig {
338
346
  orderBy?: string;
339
347
  }>;
340
348
  readonly deletedBy: readonly string[];
349
+ /** `rows.existsIn`: the etl source whose rows are the table's rows. */
350
+ readonly existsIn?: readonly string[];
341
351
  readonly policies: Omit<ResolvedTablePoliciesInput, 'read'>;
342
352
  }
343
353
  interface ResolvedSourcePlan {
@@ -547,7 +557,7 @@ interface ForeignKeyInput {
547
557
  interface TableConfig {
548
558
  displayName?: string;
549
559
  description?: string;
550
- /** Table-level migration rename hint (new in v2). */
560
+ /** Table-level migration rename hint. */
551
561
  renamedFrom?: string;
552
562
  /** Composite primary key: property names (or column names). Mutually exclusive with column-level `.primaryKey()`. */
553
563
  primaryKey?: readonly string[];
package/dist/authoring.js CHANGED
@@ -1123,6 +1123,48 @@ var policyExpressionSchema = z.union([
1123
1123
  variants: z.array(policyVariantSchema).max(10).default([])
1124
1124
  })
1125
1125
  ]);
1126
+
1127
+ // src/canonical/resolved-rows.ts
1128
+ function logicalKey(table) {
1129
+ return (table.primaryKey ?? []).map((columnName) => table.columns.find((c) => c.columnName === columnName)?.name ?? columnName);
1130
+ }
1131
+ function sameKey(key, expected) {
1132
+ return key.length === expected.length && key.every((name2) => expected.includes(name2));
1133
+ }
1134
+ function validateRowIdentity(input) {
1135
+ validateExistsIn(input);
1136
+ if (input.keys.length > 1) validateCompositeKey(input);
1137
+ }
1138
+ function validateExistsIn({ resolved, sourceByName, fail }) {
1139
+ const name2 = resolved.existsIn?.[0];
1140
+ if (name2 === void 0) return;
1141
+ if (sourceByName.get(name2)?.kind !== "etl") fail(`existsIn '${name2}' must be an etl source`);
1142
+ if (resolved.policies.insert !== void 0 || resolved.policies.write !== void 0) {
1143
+ fail("existsIn allows only an update policy: rows come from the etl source");
1144
+ }
1145
+ }
1146
+ function validateCompositeKey({ resolved, keys, tableByName, fail }) {
1147
+ const keyList = `(${keys.join(", ")})`;
1148
+ if (resolved.existsIn === void 0) {
1149
+ fail(`requires exactly one primary-key column: composite key ${keyList} requires existsIn`);
1150
+ return;
1151
+ }
1152
+ const writes = ["insert", "update", "write"].filter((kind) => resolved.policies[kind] !== void 0);
1153
+ if (writes.length > 0) fail(`composite key ${keyList} requires a read-only table: remove the ${writes.join("/")} policy`);
1154
+ if (resolved.materialized) fail(`composite key ${keyList} cannot be materialized`);
1155
+ for (const source of resolved.sources) {
1156
+ if (source.kind === "prefetch") {
1157
+ fail(`composite key ${keyList} cannot have prefetch source '${source.name}'`);
1158
+ continue;
1159
+ }
1160
+ const table = tableByName.get(source.table);
1161
+ if (table && !sameKey(logicalKey(table), keys)) {
1162
+ fail(`composite key ${keyList} requires ${source.kind} source '${source.name}' to be keyed by it; table '${source.table}' is keyed by (${logicalKey(table).join(", ")})`);
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ // src/canonical/resolved.ts
1126
1168
  function parseDurationMs(input) {
1127
1169
  if (typeof input === "number") return Number.isInteger(input) && input > 0 ? input : void 0;
1128
1170
  const match = /^(\d+(?:\.\d+)?)(ms|s|m)$/.exec(input);
@@ -1199,6 +1241,13 @@ var canonicalResolvedSchema = z.object({
1199
1241
  resolve: z.array(canonicalResolveRuleSchema).max(500),
1200
1242
  /** Sources whose tombstone hides the resolved row (v1: the manual source only). */
1201
1243
  deletedBy: z.array(identifier).max(1).default([]),
1244
+ /**
1245
+ * `rows.existsIn`: the one etl source whose rows are the table's rows (read
1246
+ * directly as the row source). Only an update policy may write. It is also
1247
+ * what admits a composite primary key: a read-only table keyed exactly like
1248
+ * that source.
1249
+ */
1250
+ existsIn: z.array(identifier).length(1).optional(),
1202
1251
  /**
1203
1252
  * Materialized: the view reads an indexed internal table (`table`) that the
1204
1253
  * data plane keeps in step with `compute`, the internal view holding the
@@ -1217,8 +1266,8 @@ function validateResolvedView({ view, tables, views = [] }) {
1217
1266
  if (!resolved) return [];
1218
1267
  const errors = [];
1219
1268
  const fail = (message) => errors.push(`resolved table '${view.view}': ${message}`);
1220
- const pkColumn = view.primaryKey?.length === 1 ? view.columns.find((c) => c.columnName === view.primaryKey?.[0]) : void 0;
1221
- if (!pkColumn) {
1269
+ const keyColumns = (view.primaryKey ?? []).map((columnName) => view.columns.find((c) => c.columnName === columnName)).filter((column) => column !== void 0);
1270
+ if (keyColumns.length === 0 || keyColumns.length !== view.primaryKey?.length) {
1222
1271
  fail("requires exactly one primary-key column");
1223
1272
  return errors;
1224
1273
  }
@@ -1226,7 +1275,7 @@ function validateResolvedView({ view, tables, views = [] }) {
1226
1275
  const scope = {
1227
1276
  view,
1228
1277
  resolved,
1229
- pkColumn,
1278
+ keys: new Map(keyColumns.map((column) => [column.name, column])),
1230
1279
  dataColumns: new Map(view.columns.filter((c) => !systemNames.has(c.name) && c.cell === void 0).map((c) => [c.name, c])),
1231
1280
  sourceByName: /* @__PURE__ */ new Map(),
1232
1281
  fail
@@ -1240,6 +1289,7 @@ function validateResolvedView({ view, tables, views = [] }) {
1240
1289
  if (!table) fail(`source '${source.name}' references unknown table '${source.table}'`);
1241
1290
  else validateSource({ scope, source, table });
1242
1291
  }
1292
+ validateRowIdentity({ resolved, keys: [...scope.keys.keys()], sourceByName: scope.sourceByName, tableByName, fail });
1243
1293
  validateRules(scope);
1244
1294
  validateViewCells(scope);
1245
1295
  if (resolved.materialized) validateMaterialized({ scope, tableByName, views });
@@ -1269,7 +1319,7 @@ function validateSource({ scope, source, table }) {
1269
1319
  validatePrefetchFields({ scope, source });
1270
1320
  }
1271
1321
  function validatePrefetchFields({ scope, source }) {
1272
- const { fail, pkColumn } = scope;
1322
+ const { fail, keys } = scope;
1273
1323
  if (source.kind !== "prefetch") {
1274
1324
  if (source.key || source.fetch || source.function || source.prefetchDefaults || source.writeback) {
1275
1325
  fail(`only prefetch sources may declare key/fetch/function/prefetchDefaults/writeback ('${source.name}')`);
@@ -1278,7 +1328,7 @@ function validatePrefetchFields({ scope, source }) {
1278
1328
  }
1279
1329
  if (!source.key || !source.fetch || !source.function || !source.prefetchDefaults) {
1280
1330
  fail(`prefetch source '${source.name}' requires key, fetch, function and prefetchDefaults`);
1281
- } else if (source.key !== pkColumn.name && !source.columns.includes(source.key)) {
1331
+ } else if (!keys.has(source.key) && !source.columns.includes(source.key)) {
1282
1332
  fail(`prefetch source '${source.name}' key '${source.key}' must be the primary key or one of its columns`);
1283
1333
  }
1284
1334
  for (const column of source.writeback?.columns ?? []) {
@@ -1286,19 +1336,19 @@ function validatePrefetchFields({ scope, source }) {
1286
1336
  }
1287
1337
  }
1288
1338
  function validateSourceColumns({ scope, source, byName }) {
1289
- const { fail, pkColumn, dataColumns } = scope;
1290
- for (const column of [pkColumn.name, ...source.columns]) {
1291
- const own = column === pkColumn.name ? pkColumn : dataColumns.get(column);
1339
+ const { fail, keys, dataColumns } = scope;
1340
+ for (const column of [...keys.keys(), ...source.columns]) {
1341
+ const own = keys.get(column) ?? dataColumns.get(column);
1292
1342
  const theirs = byName.get(column);
1293
1343
  if (!own) fail(`source '${source.name}' supplies unknown column '${column}'`);
1294
1344
  else if (!theirs) fail(`source '${source.name}' table '${source.table}' has no column '${column}'`);
1295
1345
  else if (JSON.stringify(theirs.type) !== JSON.stringify(own.type)) fail(`column '${column}' has a different type on source '${source.name}'`);
1296
1346
  }
1297
1347
  }
1298
- function validateRules({ resolved, pkColumn, dataColumns, sourceByName, fail }) {
1348
+ function validateRules({ resolved, keys, dataColumns, sourceByName, fail }) {
1299
1349
  const ruled = /* @__PURE__ */ new Set();
1300
1350
  for (const rule of resolved.resolve) {
1301
- if (!dataColumns.has(rule.column) || rule.column === pkColumn.name) {
1351
+ if (!dataColumns.has(rule.column) || keys.has(rule.column)) {
1302
1352
  fail(`resolve rule targets unknown column '${rule.column}'`);
1303
1353
  continue;
1304
1354
  }
@@ -1315,7 +1365,7 @@ function validateRules({ resolved, pkColumn, dataColumns, sourceByName, fail })
1315
1365
  }
1316
1366
  }
1317
1367
  for (const column of dataColumns.keys()) {
1318
- if (column !== pkColumn.name && !ruled.has(column)) fail(`column '${column}' has no resolve rule`);
1368
+ if (!keys.has(column) && !ruled.has(column)) fail(`column '${column}' has no resolve rule`);
1319
1369
  }
1320
1370
  }
1321
1371
  var VIEW_CELL_TYPES = {
@@ -1324,10 +1374,10 @@ var VIEW_CELL_TYPES = {
1324
1374
  by: "text",
1325
1375
  conflicts: "jsonb"
1326
1376
  };
1327
- function validateViewCells({ view, dataColumns, pkColumn, fail }) {
1377
+ function validateViewCells({ view, dataColumns, keys, fail }) {
1328
1378
  const expected = /* @__PURE__ */ new Map();
1329
1379
  for (const column of dataColumns.values()) {
1330
- if (column.name === pkColumn.name) continue;
1380
+ if (keys.has(column.name)) continue;
1331
1381
  for (const part of RESOLVED_VIEW_CELL_PARTS) {
1332
1382
  expected.set(cellColumnName(column.name, part), { part, columnName: cellColumnName(column.columnName, part) });
1333
1383
  }
@@ -1695,7 +1745,7 @@ var canonicalTableSchema = z.object({
1695
1745
  table: identifier2,
1696
1746
  displayName: z.string().trim().min(1).max(120).optional(),
1697
1747
  description: z.string().max(2e3).optional(),
1698
- /** Table-level rename hint (new in v2). */
1748
+ /** Table-level rename hint. */
1699
1749
  renamedFrom: identifier2.optional(),
1700
1750
  /** Synced/reflected read-only tables (`source: schema.table`) — v1 semantics. */
1701
1751
  source: z.string().regex(QUALIFIED_TABLE_PATTERN).optional(),
@@ -2200,8 +2250,9 @@ function planSource(input) {
2200
2250
  }
2201
2251
  var tombstoned = () => t.boolean(RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName).notNull().default(false);
2202
2252
  function internalTable(input, supplied, system, options = {}) {
2203
- const { viewName, sourceName, columns, pk, pkBuilder } = input;
2204
- const tableColumns = { [pk]: cloneColumn({ builder: pkBuilder, property: pk, notNull: true, primaryKey: true }) };
2253
+ const { viewName, sourceName, columns, keys } = input;
2254
+ const tableColumns = {};
2255
+ for (const key of keys) tableColumns[key] = cloneColumn({ builder: columns[key], property: key, notNull: true, primaryKey: true });
2205
2256
  for (const property of supplied) {
2206
2257
  tableColumns[property] = cloneColumn({ builder: columns[property], property, notNull: false, primaryKey: false });
2207
2258
  }
@@ -2218,12 +2269,14 @@ function assertColumns(input, list, what) {
2218
2269
  for (const column of list) if (!input.dataColumns.includes(column)) input.fail(`${what} names unknown column "${column}"`);
2219
2270
  }
2220
2271
  function planEtl(input, source) {
2221
- const { sourceName, pk, dataColumns, fail } = input;
2272
+ const { sourceName, keys, dataColumns, fail } = input;
2222
2273
  const table = typeof source.table === "function" ? source.table() : source.table;
2223
2274
  if (!isSeqTable(table) || table.source === void 0) fail(`etl source "${sourceName}" must reference a defineSyncedTable`);
2224
2275
  const synced = table;
2225
2276
  if (synced.config.primaryKey?.length === 0) fail(`etl source "${sourceName}" cannot use an unkeyed synced table for resolved row identity`);
2226
- if (!(pk in synced.columns)) fail(`etl source "${sourceName}" table "${synced.tableName}" has no "${pk}" column`);
2277
+ for (const key of keys) {
2278
+ if (!(key in synced.columns)) fail(`etl source "${sourceName}" table "${synced.tableName}" has no "${key}" column`);
2279
+ }
2227
2280
  const supplied = source.columns ? [...source.columns] : dataColumns.filter((column) => column in synced.columns);
2228
2281
  assertColumns(input, supplied, `etl source "${sourceName}"`);
2229
2282
  if (source.updatedAt !== void 0 && !(source.updatedAt in synced.columns)) {
@@ -2232,9 +2285,9 @@ function planEtl(input, source) {
2232
2285
  return { name: sourceName, kind: "etl", table: synced, columns: supplied, ...source.updatedAt ? { updatedAt: source.updatedAt } : {} };
2233
2286
  }
2234
2287
  function planPrefetch(input, source) {
2235
- const { sourceName, pk, fail } = input;
2288
+ const { sourceName, keys, fail } = input;
2236
2289
  assertColumns(input, source.columns, `prefetch source "${sourceName}"`);
2237
- if (source.key !== pk && !source.columns.includes(source.key)) {
2290
+ if (!keys.includes(source.key) && !source.columns.includes(source.key)) {
2238
2291
  fail(`prefetch source "${sourceName}" key "${source.key}" must be the primary key or one of its columns`);
2239
2292
  }
2240
2293
  for (const column of source.writeback?.columns ?? []) {
@@ -2287,7 +2340,7 @@ var PG_TYPE = {
2287
2340
  var ident = (value) => `"${value.replace(/"/g, '""')}"`;
2288
2341
  var literal = (value) => `'${value.replace(/'/g, "''")}'`;
2289
2342
  var physical = ({ source, property }) => source.table.columns[property].columnNameFor(property);
2290
- function candidateSql({ source, column, pk, pgType }) {
2343
+ function candidateSql({ source, column, key, pgType }) {
2291
2344
  const a = ident(`s_${source.name}`);
2292
2345
  const name2 = physical({ source, property: column });
2293
2346
  const value = `CAST(${a}.${ident(name2)} AS ${pgType})`;
@@ -2295,7 +2348,7 @@ function candidateSql({ source, column, pk, pgType }) {
2295
2348
  return {
2296
2349
  source: source.name,
2297
2350
  value,
2298
- has: `(${a}.${ident(physical({ source, property: pk }))} IS NOT NULL)`,
2351
+ has: `(${a}.${ident(physical({ source, property: key }))} IS NOT NULL)`,
2299
2352
  at: source.updatedAt ? `CAST(${a}.${ident(physical({ source, property: source.updatedAt }))} AS timestamptz)` : "CAST(NULL AS timestamptz)",
2300
2353
  by: "CAST(NULL AS text)"
2301
2354
  };
@@ -2359,6 +2412,12 @@ function builtinCell({ comparator, candidates }) {
2359
2412
  }
2360
2413
  };
2361
2414
  }
2415
+ function existsInCell(candidate) {
2416
+ return {
2417
+ value: candidate.value,
2418
+ parts: { source: literal(candidate.source), at: candidate.at, by: candidate.by, conflicts: "CAST(NULL AS jsonb)" }
2419
+ };
2420
+ }
2362
2421
  function customCell({ candidates, orderBy, index }) {
2363
2422
  const w = ident(`w_${index}`);
2364
2423
  const rows = candidates.map((c, ord) => `(${literal(c.source)}, ${c.has}, ${c.value}, ${c.at}, ${c.by}, ${ord})`);
@@ -2377,36 +2436,44 @@ function customCell({ candidates, orderBy, index }) {
2377
2436
  }
2378
2437
  function buildResolvedQuery({ context, plan }) {
2379
2438
  const { qb, relation } = context;
2380
- const pkColumn = plan.columns[plan.pk].columnNameFor(plan.pk);
2439
+ const keyColumns = plan.keys.map((key) => plan.columns[key].columnNameFor(key));
2381
2440
  const alias = (source) => ident(`s_${source}`);
2382
2441
  const tombstoned2 = RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName;
2383
2442
  const sourceByName = new Map(plan.plans.map((source) => [source.name, source]));
2384
2443
  const schema = getTableConfig(relation(plan.plans[0].table)).schema;
2444
+ const keySource = plan.existsIn === void 0 ? void 0 : sourceByName.get(plan.existsIn);
2445
+ const keyRefs = plan.keys.map((key, index) => keySource ? `${alias(keySource.name)}.${ident(physical({ source: keySource, property: key }))}` : `"k".${ident(keyColumns[index])}`);
2385
2446
  const keyBranches = plan.plans.map((source) => {
2386
2447
  const where = source.kind === "prefetch" ? sql.raw(` WHERE NOT ${ident(tombstoned2)}`) : sql.raw("");
2387
- const sourcePk = ident(physical({ source, property: plan.pk }));
2388
- const selectedPk = sourcePk === ident(pkColumn) ? sourcePk : `${sourcePk} AS ${ident(pkColumn)}`;
2389
- return sql`SELECT ${sql.raw(selectedPk)} FROM ${relation(source.table)}${where}`;
2448
+ const selectedKey = plan.keys.map((key, index) => {
2449
+ const sourceKey = ident(physical({ source, property: key }));
2450
+ return sourceKey === ident(keyColumns[index]) ? sourceKey : `${sourceKey} AS ${ident(keyColumns[index])}`;
2451
+ });
2452
+ return sql`SELECT ${sql.raw(selectedKey.join(", "))} FROM ${relation(source.table)}${where}`;
2453
+ });
2454
+ const base2 = keySource ? sql`${relation(keySource.table)} AS ${sql.raw(alias(keySource.name))}` : sql`(${sql.join(keyBranches, sql.raw(" UNION "))}) AS "k"`;
2455
+ const joins = plan.plans.filter((source) => source !== keySource).map((source) => {
2456
+ const on = plan.keys.map((key, index) => `${alias(source.name)}.${ident(physical({ source, property: key }))} = ${keyRefs[index]}`);
2457
+ return sql` LEFT JOIN ${relation(source.table)} AS ${sql.raw(alias(source.name))} ON ${sql.raw(on.join(" AND "))}`;
2390
2458
  });
2391
- const joins = plan.plans.map(
2392
- (source) => sql` LEFT JOIN ${relation(source.table)} AS ${sql.raw(alias(source.name))} ON ${sql.raw(`${alias(source.name)}.${ident(physical({ source, property: plan.pk }))} = "k".${ident(pkColumn)}`)}`
2393
- );
2394
2459
  const cells = /* @__PURE__ */ new Map();
2395
2460
  plan.rules.forEach((rule, index) => {
2396
2461
  const canonical = plan.columns[rule.column].toCanonicalColumn(rule.column);
2397
2462
  const pgType = typeof canonical.type === "string" ? PG_TYPE[canonical.type] : `${schema === void 0 ? "" : `${ident(schema)}.`}${ident(canonical.type.enum)}`;
2398
- const candidates = rule.candidates.map((name2) => candidateSql({ source: sourceByName.get(name2), column: rule.column, pk: plan.pk, pgType }));
2399
- cells.set(rule.column, rule.comparator === "custom" ? customCell({ candidates, orderBy: rule.orderBy, index }) : builtinCell({ comparator: rule.comparator, candidates }));
2463
+ const candidates = rule.candidates.map((name2) => candidateSql({ source: sourceByName.get(name2), column: rule.column, key: plan.keys[0], pgType }));
2464
+ cells.set(rule.column, keySource && rule.candidates.length === 1 && rule.candidates[0] === keySource.name ? existsInCell(candidates[0]) : rule.comparator === "custom" ? customCell({ candidates, orderBy: rule.orderBy, index }) : builtinCell({ comparator: rule.comparator, candidates }));
2400
2465
  });
2401
2466
  const laterals = [...cells.values()].flatMap((cell) => cell.lateral ? [sql.raw(cell.lateral)] : []);
2402
- const from = sql.join([sql`(${sql.join(keyBranches, sql.raw(" UNION "))}) AS "k"`, ...joins, ...laterals], sql.raw(""));
2403
- const selected = {
2404
- [plan.pk]: sql.raw(`"k".${ident(pkColumn)}`).as(pkColumn)
2405
- };
2467
+ const from = sql.join([base2, ...joins, ...laterals], sql.raw(""));
2468
+ const selected = {};
2406
2469
  for (const [property, builder] of Object.entries(plan.columns)) {
2407
- if (property === plan.pk) continue;
2408
- const cell = cells.get(property);
2409
2470
  const columnName = builder.columnNameFor(property);
2471
+ const keyIndex = plan.keys.indexOf(property);
2472
+ if (keyIndex !== -1) {
2473
+ selected[property] = sql.raw(keyRefs[keyIndex]).as(columnName);
2474
+ continue;
2475
+ }
2476
+ const cell = cells.get(property);
2410
2477
  selected[property] = sql.raw(cell.value).as(columnName);
2411
2478
  for (const [part, expression] of Object.entries(cell.parts)) {
2412
2479
  selected[cellColumnName(property, part)] = sql.raw(expression).as(cellColumnName(columnName, part));
@@ -2420,6 +2487,44 @@ function buildResolvedQuery({ context, plan }) {
2420
2487
  const query = qb.select(selected).from(from);
2421
2488
  return hidden.length > 0 ? query.where(sql.raw(hidden.join(" AND "))) : query;
2422
2489
  }
2490
+
2491
+ // src/authoring/resolved-rows.ts
2492
+ function planKeys({ columns, config, fail }) {
2493
+ const keys = Object.entries(columns).filter(([, builder]) => builder.isPrimaryKey).map(([property]) => property);
2494
+ if (keys.length === 0) fail("declare exactly one .primaryKey() column");
2495
+ if (keys.length === 1) return keys;
2496
+ const key = `(${keys.join(", ")})`;
2497
+ if (config.rows?.existsIn === void 0) fail(`declare exactly one .primaryKey() column: composite key ${key} requires rows.existsIn`);
2498
+ const writes = ["insert", "update", "write"].filter((kind) => config.policies[kind] !== void 0);
2499
+ if (writes.length > 0) fail(`composite key ${key} requires a read-only table: remove the ${writes.join("/")} policy`);
2500
+ if (config.materialize) fail(`composite key ${key} cannot be materialized`);
2501
+ const prefetch = Object.entries(config.sources).find(([, source]) => source.kind === "prefetch");
2502
+ if (prefetch) fail(`composite key ${key} cannot have prefetch source "${prefetch[0]}"`);
2503
+ return keys;
2504
+ }
2505
+ function assertCompositeSourceKeys({ keys, plans, fail }) {
2506
+ if (keys.length === 1) return;
2507
+ for (const plan of plans) {
2508
+ if (plan.kind !== "etl") continue;
2509
+ const sourceKey = declaredPrimaryKeyProperties(plan.table);
2510
+ if (sourceKey.length !== keys.length || !keys.every((key) => sourceKey.includes(key))) {
2511
+ fail(`composite key (${keys.join(", ")}) requires etl source "${plan.name}" to be keyed by it; table "${plan.table.tableName}" is keyed by (${sourceKey.join(", ")})`);
2512
+ }
2513
+ }
2514
+ }
2515
+ function planExistsIn({ config, plans, fail }) {
2516
+ const names = config.rows?.existsIn;
2517
+ if (names === void 0) return void 0;
2518
+ if (names.length !== 1) fail("rows.existsIn names exactly one etl source");
2519
+ const source = plans.find((plan) => plan.name === names[0]);
2520
+ if (source?.kind !== "etl") return fail(`rows.existsIn "${names[0]}" must be an etl source`);
2521
+ if (config.policies.insert !== void 0 || config.policies.write !== void 0) fail("rows.existsIn allows only an update policy: rows come from the etl source");
2522
+ return source;
2523
+ }
2524
+ function inheritedNotNullColumns({ existsIn, rules }) {
2525
+ if (!existsIn) return /* @__PURE__ */ new Set();
2526
+ return new Set(rules.filter((rule) => rule.candidates.length === 1 && rule.candidates[0] === existsIn.name).map((rule) => rule.column).filter((column) => existsIn.table.columns[column]?.toCanonicalColumn(column).nullable === false));
2527
+ }
2423
2528
  function managedFn(id) {
2424
2529
  return { kind: "managed", id };
2425
2530
  }
@@ -2458,26 +2563,28 @@ function defineResolvedTable(name2, columns, config) {
2458
2563
  fail(`column "${property}" is too long for a resolved table (max ${MAX_TRACKED_COLUMN_NAME} characters, room for cell parts)`);
2459
2564
  }
2460
2565
  }
2461
- const pkEntries = Object.entries(columns).filter(([, builder]) => builder.isPrimaryKey);
2462
- if (pkEntries.length !== 1) fail("declare exactly one .primaryKey() column");
2463
- const [pk, pkBuilder] = pkEntries[0];
2464
- const dataColumns = Object.keys(columns).filter((property) => property !== pk);
2566
+ const keys = planKeys({ columns, config, fail });
2567
+ const dataColumns = Object.keys(columns).filter((property) => !keys.includes(property));
2465
2568
  if (dataColumns.length === 0) fail("declare at least one non-key column");
2466
2569
  const sourceEntries = Object.entries(config.sources);
2467
2570
  if (sourceEntries.filter(([, source]) => source.kind === "manual").length !== 1) fail("declare exactly one manualSource()");
2468
2571
  const plans = sourceEntries.map(
2469
- ([sourceName, input]) => planSource({ viewName: name2, sourceName, input, columns, pk, pkBuilder, dataColumns, fail })
2572
+ ([sourceName, input]) => planSource({ viewName: name2, sourceName, input, columns, keys, dataColumns, fail })
2470
2573
  );
2471
2574
  const rules = planRules({ config, plans, dataColumns, fail });
2472
2575
  const deletedBy = [...config.rows?.deletedBy ?? []];
2473
2576
  for (const source of deletedBy) {
2474
2577
  if (plans.find((plan) => plan.name === source)?.kind !== "manual") fail(`rows.deletedBy "${source}" must be the manual source`);
2475
2578
  }
2579
+ assertCompositeSourceKeys({ keys, plans, fail });
2580
+ const existsIn = planExistsIn({ config, plans, fail });
2581
+ const notNullColumns = inheritedNotNullColumns({ existsIn, rules });
2476
2582
  const partType = { source: t.text, at: t.timestamptz, by: t.text, conflicts: t.jsonb };
2477
2583
  const viewColumns = {};
2478
2584
  for (const [property, builder] of Object.entries(columns)) {
2479
- viewColumns[property] = cloneColumn({ builder, property, notNull: property === pk, primaryKey: property === pk });
2480
- if (property === pk) continue;
2585
+ const isKey = keys.includes(property);
2586
+ viewColumns[property] = cloneColumn({ builder, property, notNull: isKey || notNullColumns.has(property), primaryKey: isKey });
2587
+ if (isKey) continue;
2481
2588
  const columnName = builder.columnNameFor(property);
2482
2589
  for (const part of RESOLVED_VIEW_CELL_PARTS) {
2483
2590
  viewColumns[cellColumnName(property, part)] = partType[part](cellColumnName(columnName, part)).cellPart(property, part);
@@ -2490,9 +2597,10 @@ function defineResolvedTable(name2, columns, config) {
2490
2597
  sources: plans,
2491
2598
  rules,
2492
2599
  deletedBy,
2600
+ ...existsIn ? { existsIn: [existsIn.name] } : {},
2493
2601
  policies: writePolicies
2494
2602
  };
2495
- const queryPlan = { columns, pk, plans, rules, deletedBy };
2603
+ const queryPlan = { columns, keys, plans, rules, deletedBy, ...existsIn ? { existsIn: existsIn.name } : {} };
2496
2604
  const typedColumns = viewColumns;
2497
2605
  const surface = {
2498
2606
  ...config.description !== void 0 ? { description: config.description } : {},
@@ -3055,6 +3163,7 @@ function buildCanonicalResolved(resolved) {
3055
3163
  })),
3056
3164
  resolve: resolved.rules.map((rule) => ({ ...rule, candidates: [...rule.candidates] })),
3057
3165
  deletedBy: [...resolved.deletedBy],
3166
+ ...resolved.existsIn !== void 0 ? { existsIn: [...resolved.existsIn] } : {},
3058
3167
  ...resolved.materialized ? { materialized: { ...resolved.materialized } } : {},
3059
3168
  policies
3060
3169
  };