@sequenceholdings/orm 2.8.4 → 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.
@@ -1126,6 +1126,48 @@ var policyExpressionSchema = zod.z.union([
1126
1126
  variants: zod.z.array(policyVariantSchema).max(10).default([])
1127
1127
  })
1128
1128
  ]);
1129
+
1130
+ // src/canonical/resolved-rows.ts
1131
+ function logicalKey(table) {
1132
+ return (table.primaryKey ?? []).map((columnName) => table.columns.find((c) => c.columnName === columnName)?.name ?? columnName);
1133
+ }
1134
+ function sameKey(key, expected) {
1135
+ return key.length === expected.length && key.every((name2) => expected.includes(name2));
1136
+ }
1137
+ function validateRowIdentity(input) {
1138
+ validateExistsIn(input);
1139
+ if (input.keys.length > 1) validateCompositeKey(input);
1140
+ }
1141
+ function validateExistsIn({ resolved, sourceByName, fail }) {
1142
+ const name2 = resolved.existsIn?.[0];
1143
+ if (name2 === void 0) return;
1144
+ if (sourceByName.get(name2)?.kind !== "etl") fail(`existsIn '${name2}' must be an etl source`);
1145
+ if (resolved.policies.insert !== void 0 || resolved.policies.write !== void 0) {
1146
+ fail("existsIn allows only an update policy: rows come from the etl source");
1147
+ }
1148
+ }
1149
+ function validateCompositeKey({ resolved, keys, tableByName, fail }) {
1150
+ const keyList = `(${keys.join(", ")})`;
1151
+ if (resolved.existsIn === void 0) {
1152
+ fail(`requires exactly one primary-key column: composite key ${keyList} requires existsIn`);
1153
+ return;
1154
+ }
1155
+ const writes = ["insert", "update", "write"].filter((kind) => resolved.policies[kind] !== void 0);
1156
+ if (writes.length > 0) fail(`composite key ${keyList} requires a read-only table: remove the ${writes.join("/")} policy`);
1157
+ if (resolved.materialized) fail(`composite key ${keyList} cannot be materialized`);
1158
+ for (const source of resolved.sources) {
1159
+ if (source.kind === "prefetch") {
1160
+ fail(`composite key ${keyList} cannot have prefetch source '${source.name}'`);
1161
+ continue;
1162
+ }
1163
+ const table = tableByName.get(source.table);
1164
+ if (table && !sameKey(logicalKey(table), keys)) {
1165
+ fail(`composite key ${keyList} requires ${source.kind} source '${source.name}' to be keyed by it; table '${source.table}' is keyed by (${logicalKey(table).join(", ")})`);
1166
+ }
1167
+ }
1168
+ }
1169
+
1170
+ // src/canonical/resolved.ts
1129
1171
  function parseDurationMs(input) {
1130
1172
  if (typeof input === "number") return Number.isInteger(input) && input > 0 ? input : void 0;
1131
1173
  const match = /^(\d+(?:\.\d+)?)(ms|s|m)$/.exec(input);
@@ -1202,6 +1244,13 @@ var canonicalResolvedSchema = zod.z.object({
1202
1244
  resolve: zod.z.array(canonicalResolveRuleSchema).max(500),
1203
1245
  /** Sources whose tombstone hides the resolved row (v1: the manual source only). */
1204
1246
  deletedBy: zod.z.array(identifier).max(1).default([]),
1247
+ /**
1248
+ * `rows.existsIn`: the one etl source whose rows are the table's rows (read
1249
+ * directly as the row source). Only an update policy may write. It is also
1250
+ * what admits a composite primary key: a read-only table keyed exactly like
1251
+ * that source.
1252
+ */
1253
+ existsIn: zod.z.array(identifier).length(1).optional(),
1205
1254
  /**
1206
1255
  * Materialized: the view reads an indexed internal table (`table`) that the
1207
1256
  * data plane keeps in step with `compute`, the internal view holding the
@@ -1220,8 +1269,8 @@ function validateResolvedView({ view, tables, views = [] }) {
1220
1269
  if (!resolved) return [];
1221
1270
  const errors = [];
1222
1271
  const fail = (message) => errors.push(`resolved table '${view.view}': ${message}`);
1223
- const pkColumn = view.primaryKey?.length === 1 ? view.columns.find((c) => c.columnName === view.primaryKey?.[0]) : void 0;
1224
- if (!pkColumn) {
1272
+ const keyColumns = (view.primaryKey ?? []).map((columnName) => view.columns.find((c) => c.columnName === columnName)).filter((column) => column !== void 0);
1273
+ if (keyColumns.length === 0 || keyColumns.length !== view.primaryKey?.length) {
1225
1274
  fail("requires exactly one primary-key column");
1226
1275
  return errors;
1227
1276
  }
@@ -1229,7 +1278,7 @@ function validateResolvedView({ view, tables, views = [] }) {
1229
1278
  const scope = {
1230
1279
  view,
1231
1280
  resolved,
1232
- pkColumn,
1281
+ keys: new Map(keyColumns.map((column) => [column.name, column])),
1233
1282
  dataColumns: new Map(view.columns.filter((c) => !systemNames.has(c.name) && c.cell === void 0).map((c) => [c.name, c])),
1234
1283
  sourceByName: /* @__PURE__ */ new Map(),
1235
1284
  fail
@@ -1243,6 +1292,7 @@ function validateResolvedView({ view, tables, views = [] }) {
1243
1292
  if (!table) fail(`source '${source.name}' references unknown table '${source.table}'`);
1244
1293
  else validateSource({ scope, source, table });
1245
1294
  }
1295
+ validateRowIdentity({ resolved, keys: [...scope.keys.keys()], sourceByName: scope.sourceByName, tableByName, fail });
1246
1296
  validateRules(scope);
1247
1297
  validateViewCells(scope);
1248
1298
  if (resolved.materialized) validateMaterialized({ scope, tableByName, views });
@@ -1272,7 +1322,7 @@ function validateSource({ scope, source, table }) {
1272
1322
  validatePrefetchFields({ scope, source });
1273
1323
  }
1274
1324
  function validatePrefetchFields({ scope, source }) {
1275
- const { fail, pkColumn } = scope;
1325
+ const { fail, keys } = scope;
1276
1326
  if (source.kind !== "prefetch") {
1277
1327
  if (source.key || source.fetch || source.function || source.prefetchDefaults || source.writeback) {
1278
1328
  fail(`only prefetch sources may declare key/fetch/function/prefetchDefaults/writeback ('${source.name}')`);
@@ -1281,7 +1331,7 @@ function validatePrefetchFields({ scope, source }) {
1281
1331
  }
1282
1332
  if (!source.key || !source.fetch || !source.function || !source.prefetchDefaults) {
1283
1333
  fail(`prefetch source '${source.name}' requires key, fetch, function and prefetchDefaults`);
1284
- } else if (source.key !== pkColumn.name && !source.columns.includes(source.key)) {
1334
+ } else if (!keys.has(source.key) && !source.columns.includes(source.key)) {
1285
1335
  fail(`prefetch source '${source.name}' key '${source.key}' must be the primary key or one of its columns`);
1286
1336
  }
1287
1337
  for (const column of source.writeback?.columns ?? []) {
@@ -1289,19 +1339,19 @@ function validatePrefetchFields({ scope, source }) {
1289
1339
  }
1290
1340
  }
1291
1341
  function validateSourceColumns({ scope, source, byName }) {
1292
- const { fail, pkColumn, dataColumns } = scope;
1293
- for (const column of [pkColumn.name, ...source.columns]) {
1294
- const own = column === pkColumn.name ? pkColumn : dataColumns.get(column);
1342
+ const { fail, keys, dataColumns } = scope;
1343
+ for (const column of [...keys.keys(), ...source.columns]) {
1344
+ const own = keys.get(column) ?? dataColumns.get(column);
1295
1345
  const theirs = byName.get(column);
1296
1346
  if (!own) fail(`source '${source.name}' supplies unknown column '${column}'`);
1297
1347
  else if (!theirs) fail(`source '${source.name}' table '${source.table}' has no column '${column}'`);
1298
1348
  else if (JSON.stringify(theirs.type) !== JSON.stringify(own.type)) fail(`column '${column}' has a different type on source '${source.name}'`);
1299
1349
  }
1300
1350
  }
1301
- function validateRules({ resolved, pkColumn, dataColumns, sourceByName, fail }) {
1351
+ function validateRules({ resolved, keys, dataColumns, sourceByName, fail }) {
1302
1352
  const ruled = /* @__PURE__ */ new Set();
1303
1353
  for (const rule of resolved.resolve) {
1304
- if (!dataColumns.has(rule.column) || rule.column === pkColumn.name) {
1354
+ if (!dataColumns.has(rule.column) || keys.has(rule.column)) {
1305
1355
  fail(`resolve rule targets unknown column '${rule.column}'`);
1306
1356
  continue;
1307
1357
  }
@@ -1318,7 +1368,7 @@ function validateRules({ resolved, pkColumn, dataColumns, sourceByName, fail })
1318
1368
  }
1319
1369
  }
1320
1370
  for (const column of dataColumns.keys()) {
1321
- if (column !== pkColumn.name && !ruled.has(column)) fail(`column '${column}' has no resolve rule`);
1371
+ if (!keys.has(column) && !ruled.has(column)) fail(`column '${column}' has no resolve rule`);
1322
1372
  }
1323
1373
  }
1324
1374
  var VIEW_CELL_TYPES = {
@@ -1327,10 +1377,10 @@ var VIEW_CELL_TYPES = {
1327
1377
  by: "text",
1328
1378
  conflicts: "jsonb"
1329
1379
  };
1330
- function validateViewCells({ view, dataColumns, pkColumn, fail }) {
1380
+ function validateViewCells({ view, dataColumns, keys, fail }) {
1331
1381
  const expected = /* @__PURE__ */ new Map();
1332
1382
  for (const column of dataColumns.values()) {
1333
- if (column.name === pkColumn.name) continue;
1383
+ if (keys.has(column.name)) continue;
1334
1384
  for (const part of RESOLVED_VIEW_CELL_PARTS) {
1335
1385
  expected.set(cellColumnName(column.name, part), { part, columnName: cellColumnName(column.columnName, part) });
1336
1386
  }
@@ -2203,8 +2253,9 @@ function planSource(input) {
2203
2253
  }
2204
2254
  var tombstoned = () => t.boolean(RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName).notNull().default(false);
2205
2255
  function internalTable(input, supplied, system, options = {}) {
2206
- const { viewName, sourceName, columns, pk, pkBuilder } = input;
2207
- const tableColumns = { [pk]: cloneColumn({ builder: pkBuilder, property: pk, notNull: true, primaryKey: true }) };
2256
+ const { viewName, sourceName, columns, keys } = input;
2257
+ const tableColumns = {};
2258
+ for (const key of keys) tableColumns[key] = cloneColumn({ builder: columns[key], property: key, notNull: true, primaryKey: true });
2208
2259
  for (const property of supplied) {
2209
2260
  tableColumns[property] = cloneColumn({ builder: columns[property], property, notNull: false, primaryKey: false });
2210
2261
  }
@@ -2221,12 +2272,14 @@ function assertColumns(input, list, what) {
2221
2272
  for (const column of list) if (!input.dataColumns.includes(column)) input.fail(`${what} names unknown column "${column}"`);
2222
2273
  }
2223
2274
  function planEtl(input, source) {
2224
- const { sourceName, pk, dataColumns, fail } = input;
2275
+ const { sourceName, keys, dataColumns, fail } = input;
2225
2276
  const table = typeof source.table === "function" ? source.table() : source.table;
2226
2277
  if (!isSeqTable(table) || table.source === void 0) fail(`etl source "${sourceName}" must reference a defineSyncedTable`);
2227
2278
  const synced = table;
2228
2279
  if (synced.config.primaryKey?.length === 0) fail(`etl source "${sourceName}" cannot use an unkeyed synced table for resolved row identity`);
2229
- if (!(pk in synced.columns)) fail(`etl source "${sourceName}" table "${synced.tableName}" has no "${pk}" column`);
2280
+ for (const key of keys) {
2281
+ if (!(key in synced.columns)) fail(`etl source "${sourceName}" table "${synced.tableName}" has no "${key}" column`);
2282
+ }
2230
2283
  const supplied = source.columns ? [...source.columns] : dataColumns.filter((column) => column in synced.columns);
2231
2284
  assertColumns(input, supplied, `etl source "${sourceName}"`);
2232
2285
  if (source.updatedAt !== void 0 && !(source.updatedAt in synced.columns)) {
@@ -2235,9 +2288,9 @@ function planEtl(input, source) {
2235
2288
  return { name: sourceName, kind: "etl", table: synced, columns: supplied, ...source.updatedAt ? { updatedAt: source.updatedAt } : {} };
2236
2289
  }
2237
2290
  function planPrefetch(input, source) {
2238
- const { sourceName, pk, fail } = input;
2291
+ const { sourceName, keys, fail } = input;
2239
2292
  assertColumns(input, source.columns, `prefetch source "${sourceName}"`);
2240
- if (source.key !== pk && !source.columns.includes(source.key)) {
2293
+ if (!keys.includes(source.key) && !source.columns.includes(source.key)) {
2241
2294
  fail(`prefetch source "${sourceName}" key "${source.key}" must be the primary key or one of its columns`);
2242
2295
  }
2243
2296
  for (const column of source.writeback?.columns ?? []) {
@@ -2290,7 +2343,7 @@ var PG_TYPE = {
2290
2343
  var ident = (value) => `"${value.replace(/"/g, '""')}"`;
2291
2344
  var literal = (value) => `'${value.replace(/'/g, "''")}'`;
2292
2345
  var physical = ({ source, property }) => source.table.columns[property].columnNameFor(property);
2293
- function candidateSql({ source, column, pk, pgType }) {
2346
+ function candidateSql({ source, column, key, pgType }) {
2294
2347
  const a = ident(`s_${source.name}`);
2295
2348
  const name2 = physical({ source, property: column });
2296
2349
  const value = `CAST(${a}.${ident(name2)} AS ${pgType})`;
@@ -2298,7 +2351,7 @@ function candidateSql({ source, column, pk, pgType }) {
2298
2351
  return {
2299
2352
  source: source.name,
2300
2353
  value,
2301
- has: `(${a}.${ident(physical({ source, property: pk }))} IS NOT NULL)`,
2354
+ has: `(${a}.${ident(physical({ source, property: key }))} IS NOT NULL)`,
2302
2355
  at: source.updatedAt ? `CAST(${a}.${ident(physical({ source, property: source.updatedAt }))} AS timestamptz)` : "CAST(NULL AS timestamptz)",
2303
2356
  by: "CAST(NULL AS text)"
2304
2357
  };
@@ -2386,39 +2439,44 @@ function customCell({ candidates, orderBy, index }) {
2386
2439
  }
2387
2440
  function buildResolvedQuery({ context, plan }) {
2388
2441
  const { qb, relation } = context;
2389
- const pkColumn = plan.columns[plan.pk].columnNameFor(plan.pk);
2442
+ const keyColumns = plan.keys.map((key) => plan.columns[key].columnNameFor(key));
2390
2443
  const alias = (source) => ident(`s_${source}`);
2391
2444
  const tombstoned2 = RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName;
2392
2445
  const sourceByName = new Map(plan.plans.map((source) => [source.name, source]));
2393
2446
  const schema = pgCore.getTableConfig(relation(plan.plans[0].table)).schema;
2394
2447
  const keySource = plan.existsIn === void 0 ? void 0 : sourceByName.get(plan.existsIn);
2395
- const keyRef = keySource ? `${alias(keySource.name)}.${ident(physical({ source: keySource, property: plan.pk }))}` : `"k".${ident(pkColumn)}`;
2448
+ const keyRefs = plan.keys.map((key, index) => keySource ? `${alias(keySource.name)}.${ident(physical({ source: keySource, property: key }))}` : `"k".${ident(keyColumns[index])}`);
2396
2449
  const keyBranches = plan.plans.map((source) => {
2397
2450
  const where = source.kind === "prefetch" ? drizzleOrm.sql.raw(` WHERE NOT ${ident(tombstoned2)}`) : drizzleOrm.sql.raw("");
2398
- const sourcePk = ident(physical({ source, property: plan.pk }));
2399
- const selectedPk = sourcePk === ident(pkColumn) ? sourcePk : `${sourcePk} AS ${ident(pkColumn)}`;
2400
- return drizzleOrm.sql`SELECT ${drizzleOrm.sql.raw(selectedPk)} FROM ${relation(source.table)}${where}`;
2451
+ const selectedKey = plan.keys.map((key, index) => {
2452
+ const sourceKey = ident(physical({ source, property: key }));
2453
+ return sourceKey === ident(keyColumns[index]) ? sourceKey : `${sourceKey} AS ${ident(keyColumns[index])}`;
2454
+ });
2455
+ return drizzleOrm.sql`SELECT ${drizzleOrm.sql.raw(selectedKey.join(", "))} FROM ${relation(source.table)}${where}`;
2401
2456
  });
2402
2457
  const base2 = keySource ? drizzleOrm.sql`${relation(keySource.table)} AS ${drizzleOrm.sql.raw(alias(keySource.name))}` : drizzleOrm.sql`(${drizzleOrm.sql.join(keyBranches, drizzleOrm.sql.raw(" UNION "))}) AS "k"`;
2403
- const joins = plan.plans.filter((source) => source !== keySource).map(
2404
- (source) => drizzleOrm.sql` LEFT JOIN ${relation(source.table)} AS ${drizzleOrm.sql.raw(alias(source.name))} ON ${drizzleOrm.sql.raw(`${alias(source.name)}.${ident(physical({ source, property: plan.pk }))} = ${keyRef}`)}`
2405
- );
2458
+ const joins = plan.plans.filter((source) => source !== keySource).map((source) => {
2459
+ const on = plan.keys.map((key, index) => `${alias(source.name)}.${ident(physical({ source, property: key }))} = ${keyRefs[index]}`);
2460
+ return drizzleOrm.sql` LEFT JOIN ${relation(source.table)} AS ${drizzleOrm.sql.raw(alias(source.name))} ON ${drizzleOrm.sql.raw(on.join(" AND "))}`;
2461
+ });
2406
2462
  const cells = /* @__PURE__ */ new Map();
2407
2463
  plan.rules.forEach((rule, index) => {
2408
2464
  const canonical = plan.columns[rule.column].toCanonicalColumn(rule.column);
2409
2465
  const pgType = typeof canonical.type === "string" ? PG_TYPE[canonical.type] : `${schema === void 0 ? "" : `${ident(schema)}.`}${ident(canonical.type.enum)}`;
2410
- const candidates = rule.candidates.map((name2) => candidateSql({ source: sourceByName.get(name2), column: rule.column, pk: plan.pk, pgType }));
2466
+ const candidates = rule.candidates.map((name2) => candidateSql({ source: sourceByName.get(name2), column: rule.column, key: plan.keys[0], pgType }));
2411
2467
  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 }));
2412
2468
  });
2413
2469
  const laterals = [...cells.values()].flatMap((cell) => cell.lateral ? [drizzleOrm.sql.raw(cell.lateral)] : []);
2414
2470
  const from = drizzleOrm.sql.join([base2, ...joins, ...laterals], drizzleOrm.sql.raw(""));
2415
- const selected = {
2416
- [plan.pk]: drizzleOrm.sql.raw(keyRef).as(pkColumn)
2417
- };
2471
+ const selected = {};
2418
2472
  for (const [property, builder] of Object.entries(plan.columns)) {
2419
- if (property === plan.pk) continue;
2420
- const cell = cells.get(property);
2421
2473
  const columnName = builder.columnNameFor(property);
2474
+ const keyIndex = plan.keys.indexOf(property);
2475
+ if (keyIndex !== -1) {
2476
+ selected[property] = drizzleOrm.sql.raw(keyRefs[keyIndex]).as(columnName);
2477
+ continue;
2478
+ }
2479
+ const cell = cells.get(property);
2422
2480
  selected[property] = drizzleOrm.sql.raw(cell.value).as(columnName);
2423
2481
  for (const [part, expression] of Object.entries(cell.parts)) {
2424
2482
  selected[cellColumnName(property, part)] = drizzleOrm.sql.raw(expression).as(cellColumnName(columnName, part));
@@ -2432,6 +2490,44 @@ function buildResolvedQuery({ context, plan }) {
2432
2490
  const query = qb.select(selected).from(from);
2433
2491
  return hidden.length > 0 ? query.where(drizzleOrm.sql.raw(hidden.join(" AND "))) : query;
2434
2492
  }
2493
+
2494
+ // src/authoring/resolved-rows.ts
2495
+ function planKeys({ columns, config, fail }) {
2496
+ const keys = Object.entries(columns).filter(([, builder]) => builder.isPrimaryKey).map(([property]) => property);
2497
+ if (keys.length === 0) fail("declare exactly one .primaryKey() column");
2498
+ if (keys.length === 1) return keys;
2499
+ const key = `(${keys.join(", ")})`;
2500
+ if (config.rows?.existsIn === void 0) fail(`declare exactly one .primaryKey() column: composite key ${key} requires rows.existsIn`);
2501
+ const writes = ["insert", "update", "write"].filter((kind) => config.policies[kind] !== void 0);
2502
+ if (writes.length > 0) fail(`composite key ${key} requires a read-only table: remove the ${writes.join("/")} policy`);
2503
+ if (config.materialize) fail(`composite key ${key} cannot be materialized`);
2504
+ const prefetch = Object.entries(config.sources).find(([, source]) => source.kind === "prefetch");
2505
+ if (prefetch) fail(`composite key ${key} cannot have prefetch source "${prefetch[0]}"`);
2506
+ return keys;
2507
+ }
2508
+ function assertCompositeSourceKeys({ keys, plans, fail }) {
2509
+ if (keys.length === 1) return;
2510
+ for (const plan of plans) {
2511
+ if (plan.kind !== "etl") continue;
2512
+ const sourceKey = declaredPrimaryKeyProperties(plan.table);
2513
+ if (sourceKey.length !== keys.length || !keys.every((key) => sourceKey.includes(key))) {
2514
+ fail(`composite key (${keys.join(", ")}) requires etl source "${plan.name}" to be keyed by it; table "${plan.table.tableName}" is keyed by (${sourceKey.join(", ")})`);
2515
+ }
2516
+ }
2517
+ }
2518
+ function planExistsIn({ config, plans, fail }) {
2519
+ const names = config.rows?.existsIn;
2520
+ if (names === void 0) return void 0;
2521
+ if (names.length !== 1) fail("rows.existsIn names exactly one etl source");
2522
+ const source = plans.find((plan) => plan.name === names[0]);
2523
+ if (source?.kind !== "etl") return fail(`rows.existsIn "${names[0]}" must be an etl source`);
2524
+ 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");
2525
+ return source;
2526
+ }
2527
+ function inheritedNotNullColumns({ existsIn, rules }) {
2528
+ if (!existsIn) return /* @__PURE__ */ new Set();
2529
+ 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));
2530
+ }
2435
2531
  function managedFn(id) {
2436
2532
  return { kind: "managed", id };
2437
2533
  }
@@ -2470,28 +2566,28 @@ function defineResolvedTable(name2, columns, config) {
2470
2566
  fail(`column "${property}" is too long for a resolved table (max ${MAX_TRACKED_COLUMN_NAME} characters, room for cell parts)`);
2471
2567
  }
2472
2568
  }
2473
- const pkEntries = Object.entries(columns).filter(([, builder]) => builder.isPrimaryKey);
2474
- if (pkEntries.length !== 1) fail("declare exactly one .primaryKey() column");
2475
- const [pk, pkBuilder] = pkEntries[0];
2476
- const dataColumns = Object.keys(columns).filter((property) => property !== pk);
2569
+ const keys = planKeys({ columns, config, fail });
2570
+ const dataColumns = Object.keys(columns).filter((property) => !keys.includes(property));
2477
2571
  if (dataColumns.length === 0) fail("declare at least one non-key column");
2478
2572
  const sourceEntries = Object.entries(config.sources);
2479
2573
  if (sourceEntries.filter(([, source]) => source.kind === "manual").length !== 1) fail("declare exactly one manualSource()");
2480
2574
  const plans = sourceEntries.map(
2481
- ([sourceName, input]) => planSource({ viewName: name2, sourceName, input, columns, pk, pkBuilder, dataColumns, fail })
2575
+ ([sourceName, input]) => planSource({ viewName: name2, sourceName, input, columns, keys, dataColumns, fail })
2482
2576
  );
2483
2577
  const rules = planRules({ config, plans, dataColumns, fail });
2484
2578
  const deletedBy = [...config.rows?.deletedBy ?? []];
2485
2579
  for (const source of deletedBy) {
2486
2580
  if (plans.find((plan) => plan.name === source)?.kind !== "manual") fail(`rows.deletedBy "${source}" must be the manual source`);
2487
2581
  }
2582
+ assertCompositeSourceKeys({ keys, plans, fail });
2488
2583
  const existsIn = planExistsIn({ config, plans, fail });
2489
- const notNullColumns = existsIn ? inheritedNotNullColumns({ existsIn, rules }) : /* @__PURE__ */ new Set();
2584
+ const notNullColumns = inheritedNotNullColumns({ existsIn, rules });
2490
2585
  const partType = { source: t.text, at: t.timestamptz, by: t.text, conflicts: t.jsonb };
2491
2586
  const viewColumns = {};
2492
2587
  for (const [property, builder] of Object.entries(columns)) {
2493
- viewColumns[property] = cloneColumn({ builder, property, notNull: property === pk || notNullColumns.has(property), primaryKey: property === pk });
2494
- if (property === pk) continue;
2588
+ const isKey = keys.includes(property);
2589
+ viewColumns[property] = cloneColumn({ builder, property, notNull: isKey || notNullColumns.has(property), primaryKey: isKey });
2590
+ if (isKey) continue;
2495
2591
  const columnName = builder.columnNameFor(property);
2496
2592
  for (const part of RESOLVED_VIEW_CELL_PARTS) {
2497
2593
  viewColumns[cellColumnName(property, part)] = partType[part](cellColumnName(columnName, part)).cellPart(property, part);
@@ -2504,9 +2600,10 @@ function defineResolvedTable(name2, columns, config) {
2504
2600
  sources: plans,
2505
2601
  rules,
2506
2602
  deletedBy,
2603
+ ...existsIn ? { existsIn: [existsIn.name] } : {},
2507
2604
  policies: writePolicies
2508
2605
  };
2509
- const queryPlan = { columns, pk, plans, rules, deletedBy, ...existsIn ? { existsIn: existsIn.name } : {} };
2606
+ const queryPlan = { columns, keys, plans, rules, deletedBy, ...existsIn ? { existsIn: existsIn.name } : {} };
2510
2607
  const typedColumns = viewColumns;
2511
2608
  const surface = {
2512
2609
  ...config.description !== void 0 ? { description: config.description } : {},
@@ -2548,18 +2645,6 @@ function defineResolvedTable(name2, columns, config) {
2548
2645
  }
2549
2646
  });
2550
2647
  }
2551
- function planExistsIn({ config, plans, fail }) {
2552
- const names = config.rows?.existsIn;
2553
- if (names === void 0) return void 0;
2554
- if (names.length !== 1) fail("rows.existsIn names exactly one etl source");
2555
- const source = plans.find((plan) => plan.name === names[0]);
2556
- if (source?.kind !== "etl") fail(`rows.existsIn "${names[0]}" must be an etl source`);
2557
- 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");
2558
- return source;
2559
- }
2560
- function inheritedNotNullColumns({ existsIn, rules }) {
2561
- 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));
2562
- }
2563
2648
  function planRules({ config, plans, dataColumns, fail }) {
2564
2649
  const kindOrder = { manual: 0, prefetch: 1, etl: 2 };
2565
2650
  const fallback = config.default ?? priority([...plans].sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind]).map((plan) => plan.name));
@@ -3081,6 +3166,7 @@ function buildCanonicalResolved(resolved) {
3081
3166
  })),
3082
3167
  resolve: resolved.rules.map((rule) => ({ ...rule, candidates: [...rule.candidates] })),
3083
3168
  deletedBy: [...resolved.deletedBy],
3169
+ ...resolved.existsIn !== void 0 ? { existsIn: [...resolved.existsIn] } : {},
3084
3170
  ...resolved.materialized ? { materialized: { ...resolved.materialized } } : {},
3085
3171
  policies
3086
3172
  };