@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.
@@ -308,7 +308,9 @@ interface ResolvedTableConfig {
308
308
  /**
309
309
  * A row exists only while this etl source has its key; edits and other
310
310
  * sources only change values. A column resolved from this source alone
311
- * keeps the synced column's NOT NULL. Exactly one etl source.
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.
312
314
  */
313
315
  existsIn?: readonly string[];
314
316
  };
@@ -344,6 +346,8 @@ interface ResolvedViewConfig {
344
346
  orderBy?: string;
345
347
  }>;
346
348
  readonly deletedBy: readonly string[];
349
+ /** `rows.existsIn`: the etl source whose rows are the table's rows. */
350
+ readonly existsIn?: readonly string[];
347
351
  readonly policies: Omit<ResolvedTablePoliciesInput, 'read'>;
348
352
  }
349
353
  interface ResolvedSourcePlan {
@@ -308,7 +308,9 @@ interface ResolvedTableConfig {
308
308
  /**
309
309
  * A row exists only while this etl source has its key; edits and other
310
310
  * sources only change values. A column resolved from this source alone
311
- * keeps the synced column's NOT NULL. Exactly one etl source.
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.
312
314
  */
313
315
  existsIn?: readonly string[];
314
316
  };
@@ -344,6 +346,8 @@ interface ResolvedViewConfig {
344
346
  orderBy?: string;
345
347
  }>;
346
348
  readonly deletedBy: readonly string[];
349
+ /** `rows.existsIn`: the etl source whose rows are the table's rows. */
350
+ readonly existsIn?: readonly string[];
347
351
  readonly policies: Omit<ResolvedTablePoliciesInput, 'read'>;
348
352
  }
349
353
  interface ResolvedSourcePlan {
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
  }
@@ -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
  };
@@ -2383,39 +2436,44 @@ function customCell({ candidates, orderBy, index }) {
2383
2436
  }
2384
2437
  function buildResolvedQuery({ context, plan }) {
2385
2438
  const { qb, relation } = context;
2386
- const pkColumn = plan.columns[plan.pk].columnNameFor(plan.pk);
2439
+ const keyColumns = plan.keys.map((key) => plan.columns[key].columnNameFor(key));
2387
2440
  const alias = (source) => ident(`s_${source}`);
2388
2441
  const tombstoned2 = RESOLVED_SOURCE_SYSTEM_COLUMNS.tombstoned.columnName;
2389
2442
  const sourceByName = new Map(plan.plans.map((source) => [source.name, source]));
2390
2443
  const schema = getTableConfig(relation(plan.plans[0].table)).schema;
2391
2444
  const keySource = plan.existsIn === void 0 ? void 0 : sourceByName.get(plan.existsIn);
2392
- const keyRef = keySource ? `${alias(keySource.name)}.${ident(physical({ source: keySource, property: plan.pk }))}` : `"k".${ident(pkColumn)}`;
2445
+ const keyRefs = plan.keys.map((key, index) => keySource ? `${alias(keySource.name)}.${ident(physical({ source: keySource, property: key }))}` : `"k".${ident(keyColumns[index])}`);
2393
2446
  const keyBranches = plan.plans.map((source) => {
2394
2447
  const where = source.kind === "prefetch" ? sql.raw(` WHERE NOT ${ident(tombstoned2)}`) : sql.raw("");
2395
- const sourcePk = ident(physical({ source, property: plan.pk }));
2396
- const selectedPk = sourcePk === ident(pkColumn) ? sourcePk : `${sourcePk} AS ${ident(pkColumn)}`;
2397
- 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}`;
2398
2453
  });
2399
2454
  const base2 = keySource ? sql`${relation(keySource.table)} AS ${sql.raw(alias(keySource.name))}` : sql`(${sql.join(keyBranches, sql.raw(" UNION "))}) AS "k"`;
2400
- const joins = plan.plans.filter((source) => source !== keySource).map(
2401
- (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 }))} = ${keyRef}`)}`
2402
- );
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 "))}`;
2458
+ });
2403
2459
  const cells = /* @__PURE__ */ new Map();
2404
2460
  plan.rules.forEach((rule, index) => {
2405
2461
  const canonical = plan.columns[rule.column].toCanonicalColumn(rule.column);
2406
2462
  const pgType = typeof canonical.type === "string" ? PG_TYPE[canonical.type] : `${schema === void 0 ? "" : `${ident(schema)}.`}${ident(canonical.type.enum)}`;
2407
- const candidates = rule.candidates.map((name2) => candidateSql({ source: sourceByName.get(name2), column: rule.column, pk: plan.pk, pgType }));
2463
+ const candidates = rule.candidates.map((name2) => candidateSql({ source: sourceByName.get(name2), column: rule.column, key: plan.keys[0], pgType }));
2408
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 }));
2409
2465
  });
2410
2466
  const laterals = [...cells.values()].flatMap((cell) => cell.lateral ? [sql.raw(cell.lateral)] : []);
2411
2467
  const from = sql.join([base2, ...joins, ...laterals], sql.raw(""));
2412
- const selected = {
2413
- [plan.pk]: sql.raw(keyRef).as(pkColumn)
2414
- };
2468
+ const selected = {};
2415
2469
  for (const [property, builder] of Object.entries(plan.columns)) {
2416
- if (property === plan.pk) continue;
2417
- const cell = cells.get(property);
2418
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);
2419
2477
  selected[property] = sql.raw(cell.value).as(columnName);
2420
2478
  for (const [part, expression] of Object.entries(cell.parts)) {
2421
2479
  selected[cellColumnName(property, part)] = sql.raw(expression).as(cellColumnName(columnName, part));
@@ -2429,6 +2487,44 @@ function buildResolvedQuery({ context, plan }) {
2429
2487
  const query = qb.select(selected).from(from);
2430
2488
  return hidden.length > 0 ? query.where(sql.raw(hidden.join(" AND "))) : query;
2431
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
+ }
2432
2528
  function managedFn(id) {
2433
2529
  return { kind: "managed", id };
2434
2530
  }
@@ -2467,28 +2563,28 @@ function defineResolvedTable(name2, columns, config) {
2467
2563
  fail(`column "${property}" is too long for a resolved table (max ${MAX_TRACKED_COLUMN_NAME} characters, room for cell parts)`);
2468
2564
  }
2469
2565
  }
2470
- const pkEntries = Object.entries(columns).filter(([, builder]) => builder.isPrimaryKey);
2471
- if (pkEntries.length !== 1) fail("declare exactly one .primaryKey() column");
2472
- const [pk, pkBuilder] = pkEntries[0];
2473
- 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));
2474
2568
  if (dataColumns.length === 0) fail("declare at least one non-key column");
2475
2569
  const sourceEntries = Object.entries(config.sources);
2476
2570
  if (sourceEntries.filter(([, source]) => source.kind === "manual").length !== 1) fail("declare exactly one manualSource()");
2477
2571
  const plans = sourceEntries.map(
2478
- ([sourceName, input]) => planSource({ viewName: name2, sourceName, input, columns, pk, pkBuilder, dataColumns, fail })
2572
+ ([sourceName, input]) => planSource({ viewName: name2, sourceName, input, columns, keys, dataColumns, fail })
2479
2573
  );
2480
2574
  const rules = planRules({ config, plans, dataColumns, fail });
2481
2575
  const deletedBy = [...config.rows?.deletedBy ?? []];
2482
2576
  for (const source of deletedBy) {
2483
2577
  if (plans.find((plan) => plan.name === source)?.kind !== "manual") fail(`rows.deletedBy "${source}" must be the manual source`);
2484
2578
  }
2579
+ assertCompositeSourceKeys({ keys, plans, fail });
2485
2580
  const existsIn = planExistsIn({ config, plans, fail });
2486
- const notNullColumns = existsIn ? inheritedNotNullColumns({ existsIn, rules }) : /* @__PURE__ */ new Set();
2581
+ const notNullColumns = inheritedNotNullColumns({ existsIn, rules });
2487
2582
  const partType = { source: t.text, at: t.timestamptz, by: t.text, conflicts: t.jsonb };
2488
2583
  const viewColumns = {};
2489
2584
  for (const [property, builder] of Object.entries(columns)) {
2490
- viewColumns[property] = cloneColumn({ builder, property, notNull: property === pk || notNullColumns.has(property), primaryKey: property === pk });
2491
- 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;
2492
2588
  const columnName = builder.columnNameFor(property);
2493
2589
  for (const part of RESOLVED_VIEW_CELL_PARTS) {
2494
2590
  viewColumns[cellColumnName(property, part)] = partType[part](cellColumnName(columnName, part)).cellPart(property, part);
@@ -2501,9 +2597,10 @@ function defineResolvedTable(name2, columns, config) {
2501
2597
  sources: plans,
2502
2598
  rules,
2503
2599
  deletedBy,
2600
+ ...existsIn ? { existsIn: [existsIn.name] } : {},
2504
2601
  policies: writePolicies
2505
2602
  };
2506
- const queryPlan = { columns, pk, plans, rules, deletedBy, ...existsIn ? { existsIn: existsIn.name } : {} };
2603
+ const queryPlan = { columns, keys, plans, rules, deletedBy, ...existsIn ? { existsIn: existsIn.name } : {} };
2507
2604
  const typedColumns = viewColumns;
2508
2605
  const surface = {
2509
2606
  ...config.description !== void 0 ? { description: config.description } : {},
@@ -2545,18 +2642,6 @@ function defineResolvedTable(name2, columns, config) {
2545
2642
  }
2546
2643
  });
2547
2644
  }
2548
- function planExistsIn({ config, plans, fail }) {
2549
- const names = config.rows?.existsIn;
2550
- if (names === void 0) return void 0;
2551
- if (names.length !== 1) fail("rows.existsIn names exactly one etl source");
2552
- const source = plans.find((plan) => plan.name === names[0]);
2553
- if (source?.kind !== "etl") fail(`rows.existsIn "${names[0]}" must be an etl source`);
2554
- 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");
2555
- return source;
2556
- }
2557
- function inheritedNotNullColumns({ existsIn, rules }) {
2558
- 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));
2559
- }
2560
2645
  function planRules({ config, plans, dataColumns, fail }) {
2561
2646
  const kindOrder = { manual: 0, prefetch: 1, etl: 2 };
2562
2647
  const fallback = config.default ?? priority([...plans].sort((a, b) => kindOrder[a.kind] - kindOrder[b.kind]).map((plan) => plan.name));
@@ -3078,6 +3163,7 @@ function buildCanonicalResolved(resolved) {
3078
3163
  })),
3079
3164
  resolve: resolved.rules.map((rule) => ({ ...rule, candidates: [...rule.candidates] })),
3080
3165
  deletedBy: [...resolved.deletedBy],
3166
+ ...resolved.existsIn !== void 0 ? { existsIn: [...resolved.existsIn] } : {},
3081
3167
  ...resolved.materialized ? { materialized: { ...resolved.materialized } } : {},
3082
3168
  policies
3083
3169
  };