@minnowdb/core 0.4.1 → 0.6.0

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 (47) hide show
  1. package/README.md +5 -5
  2. package/dist/engine/cancellation.d.ts +2 -0
  3. package/dist/engine/cancellation.js +4 -0
  4. package/dist/engine/catalog.d.ts +5 -1
  5. package/dist/engine/catalog.js +5 -1
  6. package/dist/engine/client.d.ts +34 -6
  7. package/dist/engine/client.js +87 -19
  8. package/dist/engine/database.d.ts +43 -20
  9. package/dist/engine/database.js +823 -164
  10. package/dist/engine/defaults.js +11 -0
  11. package/dist/engine/errors.d.ts +19 -0
  12. package/dist/engine/errors.js +31 -0
  13. package/dist/engine/fts.d.ts +2 -15
  14. package/dist/engine/live.d.ts +1 -7
  15. package/dist/engine/live.js +12 -13
  16. package/dist/engine/optimizer.js +546 -39
  17. package/dist/engine/query-cache.js +1 -0
  18. package/dist/engine/query.d.ts +16 -278
  19. package/dist/engine/query.js +260 -74
  20. package/dist/engine/result-wire.d.ts +2 -0
  21. package/dist/engine/result-wire.js +21 -5
  22. package/dist/engine/schema-wire.d.ts +14 -1
  23. package/dist/engine/schema-wire.js +7 -1
  24. package/dist/engine/schema.d.ts +83 -32
  25. package/dist/engine/schema.js +180 -14
  26. package/dist/engine/sql-domains.d.ts +11 -0
  27. package/dist/engine/sql-domains.js +65 -1
  28. package/dist/engine/sql-json.js +22 -3
  29. package/dist/engine/sql-semantics.js +21 -3
  30. package/dist/engine/vector.d.ts +2 -2
  31. package/dist/engine/vector.js +328 -79
  32. package/dist/engine/worker-host.js +119 -44
  33. package/dist/plan/index.d.ts +5 -4
  34. package/dist/plan/index.js +3 -3
  35. package/dist/plan/model.d.ts +218 -0
  36. package/dist/plan/model.js +1 -0
  37. package/dist/storage/indexeddb.js +4 -12
  38. package/dist/storage/toolkit/record-core.js +7 -22
  39. package/dist/storage/types.d.ts +26 -8
  40. package/dist/storage/types.js +85 -0
  41. package/dist/transactions/index.d.ts +5 -3
  42. package/dist/transactions/index.js +58 -8
  43. package/dist/worker-protocol/index.d.ts +6 -1
  44. package/dist/worker-protocol/index.js +5 -2
  45. package/package.json +1 -1
  46. package/postgres-feature-profile.json +5 -0
  47. package/sql-feature-matrix.json +75 -19
@@ -111,7 +111,7 @@ export type SqlDomain = {
111
111
  precision?: number;
112
112
  scale?: number;
113
113
  } | {
114
- kind: "json" | "jsonb" | "uuid" | "time" | "interval";
114
+ kind: "json" | "jsonb" | "uuid" | "date" | "time" | "interval";
115
115
  } | {
116
116
  kind: "array";
117
117
  element: string;
@@ -136,6 +136,11 @@ export type ColumnDefault = {
136
136
  } | {
137
137
  kind: "autoincrement";
138
138
  };
139
+ /** A stored generated-column expression, evaluated from the row on every insert and update. */
140
+ export interface ColumnGenerated {
141
+ readonly kind: "stored";
142
+ readonly sql: string;
143
+ }
139
144
  export interface TableColumnRecord {
140
145
  id: string;
141
146
  name: string;
@@ -152,6 +157,8 @@ export interface TableColumnRecord {
152
157
  nullable: boolean;
153
158
  /** Fills omitted or SQL `DEFAULT` slots at insert time; explicit NULL is never replaced. */
154
159
  defaultValue?: ColumnDefault;
160
+ /** Recomputed from sibling columns on every insert and update; callers cannot assign it. */
161
+ generatedValue?: ColumnGenerated;
155
162
  /**
156
163
  * What rows written before this column existed read as, instead of NULL.
157
164
  *
@@ -264,6 +271,15 @@ export interface SecondaryIndexRecord {
264
271
  * accelerator. Index identity, key shape, and UNIQUE enforcement are structural.
265
272
  */
266
273
  export declare function secondaryIndexWriteContractChanged(previous: Readonly<Record<string, SecondaryIndexRecord>> | null | undefined, next: Readonly<Record<string, SecondaryIndexRecord>> | null | undefined): boolean;
274
+ export interface TableForeignKeyRecord {
275
+ name: string;
276
+ columns: string[];
277
+ parentTable: string;
278
+ parentColumns: string[];
279
+ onDelete: "restrict" | "cascade" | "set null";
280
+ /** False records relationship metadata without write/delete enforcement. Absent means true. */
281
+ enforced?: boolean;
282
+ }
267
283
  export interface TableRecord {
268
284
  id: string;
269
285
  name: string;
@@ -279,13 +295,7 @@ export interface TableRecord {
279
295
  /** BEFORE/AFTER triggers on this table, fired by the committing writer in its transaction. */
280
296
  triggers?: TriggerRecord[];
281
297
  /** FOREIGN KEY constraints. Column tuples are always explicit, including scalar keys. */
282
- foreignKeys?: Array<{
283
- name: string;
284
- columns: string[];
285
- parentTable: string;
286
- parentColumns: string[];
287
- onDelete: "restrict" | "cascade" | "set null";
288
- }>;
298
+ foreignKeys?: TableForeignKeyRecord[];
289
299
  /**
290
300
  * Row-level CHECK constraints (E141-06), each the text of a boolean expression over this
291
301
  * table's own columns. Text rather than a compiled form because the record crosses the worker
@@ -329,6 +339,12 @@ export interface TableRecord {
329
339
  /** Compare-and-swap revision for catalog evolution. */
330
340
  revision: number;
331
341
  }
342
+ /**
343
+ * Validates one relationship against its child and parent records. This is shared by every
344
+ * storage adapter and snapshot restore path so informational relationships cannot bypass the
345
+ * same structural and domain checks as enforced foreign keys.
346
+ */
347
+ export declare function validateTableForeignKey(childTable: TableRecord, key: TableForeignKeyRecord, parentTable: TableRecord): void;
332
348
  /** Exact UTF-8 bytes used when the canonical record-wire JSON codec persists a table record. */
333
349
  export declare function catalogRecordRetainedBytes(record: TableRecord): number;
334
350
  export declare function manifestRecordRetainedBytes(record: Manifest): number;
@@ -1815,6 +1831,8 @@ export interface CatalogMutationOptions {
1815
1831
  /** Atomic replacement fields for one catalog record. */
1816
1832
  export interface TableRecordUpdate extends CatalogMutationOptions {
1817
1833
  columns?: TableColumnRecord[];
1834
+ /** Replaces the complete relationship catalog. */
1835
+ foreignKeys?: TableRecord["foreignKeys"];
1818
1836
  /** Replaces the full-text index state map; null clears it. */
1819
1837
  ftsColumns?: Record<string, FtsColumnIndexRecord> | null;
1820
1838
  /** Replaces the secondary-index state map; null clears it. */
@@ -141,6 +141,22 @@ export function validateTableColumns(columns) {
141
141
  ...(column.enumValues === undefined ? {} : { enumValues: column.enumValues }),
142
142
  }, column.defaultValue);
143
143
  }
144
+ if (column.generatedValue !== undefined) {
145
+ const generated = column.generatedValue;
146
+ if (typeof generated !== "object" ||
147
+ generated === null ||
148
+ !("kind" in generated) ||
149
+ generated.kind !== "stored" ||
150
+ !("sql" in generated) ||
151
+ typeof generated.sql !== "string" ||
152
+ generated.sql.length === 0 ||
153
+ generated.sql.trim() !== generated.sql) {
154
+ throw new TypeError(`Generated SQL must be a trimmed non-empty expression: ${column.name}`);
155
+ }
156
+ if (column.defaultValue !== undefined || column.backfill !== undefined || column.hidden) {
157
+ throw new TypeError(`Generated columns cannot have defaults, backfills, or hidden metadata: ${column.name}`);
158
+ }
159
+ }
144
160
  if (column.backfill !== undefined) {
145
161
  const value = column.backfill;
146
162
  const validType = column.type === "datetime"
@@ -258,6 +274,70 @@ export function secondaryIndexWriteContractChanged(previous, next) {
258
274
  left.locator !== right.locator);
259
275
  });
260
276
  }
277
+ /**
278
+ * Validates one relationship against its child and parent records. This is shared by every
279
+ * storage adapter and snapshot restore path so informational relationships cannot bypass the
280
+ * same structural and domain checks as enforced foreign keys.
281
+ */
282
+ export function validateTableForeignKey(childTable, key, parentTable) {
283
+ if (typeof key.name !== "string" ||
284
+ key.name.length === 0 ||
285
+ typeof key.parentTable !== "string" ||
286
+ key.parentTable.length === 0 ||
287
+ !Array.isArray(key.columns) ||
288
+ !Array.isArray(key.parentColumns) ||
289
+ key.columns.length === 0 ||
290
+ key.columns.some((column) => typeof column !== "string" || column.length === 0) ||
291
+ key.parentColumns.some((column) => typeof column !== "string" || column.length === 0) ||
292
+ new Set(key.columns).size !== key.columns.length ||
293
+ new Set(key.parentColumns).size !== key.parentColumns.length ||
294
+ !["restrict", "cascade", "set null"].includes(key.onDelete) ||
295
+ (key.enforced !== undefined && typeof key.enforced !== "boolean")) {
296
+ throw new TypeError("FOREIGN KEY metadata is invalid");
297
+ }
298
+ if (key.parentTable !== parentTable.name) {
299
+ throw new TypeError(`FOREIGN KEY ${key.name} resolved to the wrong parent table`);
300
+ }
301
+ if (key.enforced === false && key.onDelete !== "restrict") {
302
+ throw new TypeError(`Informational FOREIGN KEY ${key.name} cannot declare ON DELETE actions`);
303
+ }
304
+ const children = key.columns.map((name) => childTable.columns.find((column) => column.name === name && column.hidden !== true));
305
+ if (children.some((column) => column === undefined)) {
306
+ throw new TypeError(`FOREIGN KEY ${key.name} names a column this table does not have`);
307
+ }
308
+ const addressIds = parentTable.primaryKeyColumnIds?.length
309
+ ? parentTable.primaryKeyColumnIds
310
+ : parentTable.uniqueKeyColumnId === undefined
311
+ ? []
312
+ : [parentTable.uniqueKeyColumnId];
313
+ const parents = addressIds.map((id) => parentTable.columns.find((column) => column.id === id && column.hidden !== true));
314
+ const addressNames = parents.map((column) => column?.name ?? "");
315
+ if (addressNames.length !== key.parentColumns.length ||
316
+ addressNames.some((name, index) => name !== key.parentColumns[index])) {
317
+ throw new TypeError(`FOREIGN KEY ${key.name} must reference the parent primary or unique key`);
318
+ }
319
+ if (children.length !== parents.length) {
320
+ throw new TypeError(`FOREIGN KEY ${key.name} has ${String(children.length)} child columns for ${String(parents.length)} parent columns`);
321
+ }
322
+ children.forEach((child, index) => {
323
+ const parent = parents[index];
324
+ if (child === undefined || parent === undefined) {
325
+ throw new TypeError(`FOREIGN KEY ${key.name} is missing a key column`);
326
+ }
327
+ if (child.type !== parent.type) {
328
+ throw new TypeError(`FOREIGN KEY ${key.name} compares ${child.type} with ${parent.type}`);
329
+ }
330
+ if ((child.integer === true) !== (parent.integer === true)) {
331
+ throw new TypeError(`FOREIGN KEY ${key.name} compares an integer domain with an approximate number domain`);
332
+ }
333
+ if (JSON.stringify(child.sqlDomain ?? null) !== JSON.stringify(parent.sqlDomain ?? null)) {
334
+ throw new TypeError(`FOREIGN KEY ${key.name} compares different SQL value domains`);
335
+ }
336
+ if (key.onDelete === "set null" && !child.nullable) {
337
+ throw new TypeError(`FOREIGN KEY ${key.name} cannot SET NULL a NOT NULL column`);
338
+ }
339
+ });
340
+ }
261
341
  const catalogRecordTextEncoder = new TextEncoder();
262
342
  function durableRecordRetainedBytes(record, label) {
263
343
  // Keep this byte accounting identical to the canonical record-wire JSON codec without
@@ -389,6 +469,11 @@ export function validateTableRecordBounds(record) {
389
469
  throw new RangeError(`A table cannot exceed ${String(MAX_TABLE_CONSTRAINTS)} named constraints`);
390
470
  }
391
471
  const constraintNames = new Set();
472
+ for (const key of record.foreignKeys ?? []) {
473
+ if (key.enforced !== undefined && typeof key.enforced !== "boolean") {
474
+ throw new TypeError(`FOREIGN KEY enforcement is invalid: ${key.name}`);
475
+ }
476
+ }
392
477
  for (const constraintName of namedConstraints) {
393
478
  validateCatalogName(constraintName, "Constraint name");
394
479
  if (constraintNames.has(constraintName)) {
@@ -195,9 +195,11 @@ export declare class DatabaseTransaction {
195
195
  setUniqueKeyChanges(changes: UniqueKeyChanges): void;
196
196
  /**
197
197
  * Attaches one batch's full-text deltas; applied atomically with the publish. A second
198
- * batch for the same table merges per column — postings re-sorted by term (each batch's
199
- * reserved row ids are strictly above the last, so rowIds stay ascending within a term)
200
- * and token totals summed — so a scope can insert into one FTS table any number of times.
198
+ * batch for the same table merges per column — postings stay sorted by term and row ID,
199
+ * duplicate row locators collapse to their greatest term frequency, and token totals are
200
+ * summed — so a scope can mutate one indexed table any number of times. Secondary indexes
201
+ * use stable hashed key locators rather than reserved row IDs, so operation order does not
202
+ * imply locator order.
201
203
  */
202
204
  setFtsChanges(changes: FtsChanges): void;
203
205
  /**
@@ -712,9 +712,11 @@ export class DatabaseTransaction {
712
712
  }
713
713
  /**
714
714
  * Attaches one batch's full-text deltas; applied atomically with the publish. A second
715
- * batch for the same table merges per column — postings re-sorted by term (each batch's
716
- * reserved row ids are strictly above the last, so rowIds stay ascending within a term)
717
- * and token totals summed — so a scope can insert into one FTS table any number of times.
715
+ * batch for the same table merges per column — postings stay sorted by term and row ID,
716
+ * duplicate row locators collapse to their greatest term frequency, and token totals are
717
+ * summed — so a scope can mutate one indexed table any number of times. Secondary indexes
718
+ * use stable hashed key locators rather than reserved row IDs, so operation order does not
719
+ * imply locator order.
718
720
  */
719
721
  setFtsChanges(changes) {
720
722
  this.#assertActive();
@@ -746,6 +748,7 @@ export class DatabaseTransaction {
746
748
  });
747
749
  continue;
748
750
  }
751
+ let duplicateTokens = 0;
749
752
  for (const posting of column.postings) {
750
753
  const held = present.postings.get(posting.term);
751
754
  if (held === undefined) {
@@ -756,13 +759,18 @@ export class DatabaseTransaction {
756
759
  });
757
760
  }
758
761
  else {
759
- for (const rowId of posting.rowIds)
760
- held.rowIds.push(rowId);
761
- for (const tf of posting.tf)
762
- held.tf.push(tf);
762
+ const merged = mergeFtsPostingRows(held, posting);
763
+ duplicateTokens = safeWholeNumberSum([
764
+ duplicateTokens,
765
+ postingTokenCount(held) + postingTokenCount(posting) - postingTokenCount(merged),
766
+ ], "Duplicate posting token count");
767
+ present.postings.set(posting.term, merged);
763
768
  }
764
769
  }
765
- present.totalTokens = tokenTotals.get(column.columnId) ?? present.totalTokens;
770
+ const totalTokens = tokenTotals.get(column.columnId) ?? present.totalTokens;
771
+ if (duplicateTokens > totalTokens)
772
+ throw new Error("Posting token merge is inconsistent");
773
+ present.totalTokens = totalTokens - duplicateTokens;
766
774
  }
767
775
  }
768
776
  #materializedFtsChanges() {
@@ -1615,6 +1623,48 @@ function safeWholeNumberSum(values, label) {
1615
1623
  }
1616
1624
  return total;
1617
1625
  }
1626
+ /** Linear merge of two canonical postings for one term. */
1627
+ function mergeFtsPostingRows(left, right) {
1628
+ if (left.term !== right.term)
1629
+ throw new TypeError("Posting terms differ during transaction merge");
1630
+ const rowIds = [];
1631
+ const tf = [];
1632
+ let leftIndex = 0;
1633
+ let rightIndex = 0;
1634
+ const push = (rowId, frequency) => {
1635
+ const previous = rowIds[rowIds.length - 1];
1636
+ if (previous === rowId) {
1637
+ tf[tf.length - 1] = Math.max(tf[tf.length - 1] ?? 1, frequency);
1638
+ return;
1639
+ }
1640
+ rowIds.push(rowId);
1641
+ tf.push(frequency);
1642
+ };
1643
+ while (leftIndex < left.rowIds.length && rightIndex < right.rowIds.length) {
1644
+ const leftRowId = left.rowIds[leftIndex] ?? 0n;
1645
+ const rightRowId = right.rowIds[rightIndex] ?? 0n;
1646
+ if (leftRowId <= rightRowId) {
1647
+ push(leftRowId, left.tf[leftIndex] ?? 1);
1648
+ leftIndex += 1;
1649
+ }
1650
+ if (rightRowId <= leftRowId) {
1651
+ push(rightRowId, right.tf[rightIndex] ?? 1);
1652
+ rightIndex += 1;
1653
+ }
1654
+ }
1655
+ while (leftIndex < left.rowIds.length) {
1656
+ push(left.rowIds[leftIndex] ?? 0n, left.tf[leftIndex] ?? 1);
1657
+ leftIndex += 1;
1658
+ }
1659
+ while (rightIndex < right.rowIds.length) {
1660
+ push(right.rowIds[rightIndex] ?? 0n, right.tf[rightIndex] ?? 1);
1661
+ rightIndex += 1;
1662
+ }
1663
+ return { term: left.term, rowIds, tf };
1664
+ }
1665
+ function postingTokenCount(posting) {
1666
+ return safeWholeNumberSum(posting.tf, "Posting token count");
1667
+ }
1618
1668
  function requiredCompactionJobId(id) {
1619
1669
  if (id === null)
1620
1670
  throw new Error("Compaction source blocks require a compaction job");
@@ -1,4 +1,4 @@
1
- export declare const protocolVersion: 2;
1
+ export declare const protocolVersion: 3;
2
2
  /** Outstanding request/response pairs retained by either side of one database RPC connection. */
3
3
  export declare const MAX_DATABASE_RPC_IN_FLIGHT = 256;
4
4
  export type WorkerOperation = "benchmark" | "cancelBenchmark" | "memorySample" | "datasetList" | "datasetCreate" | "datasetDelete" | "runQuery" | "suiteReference" | "suiteWrite" | "suiteFeatureMatrix" | "suiteLive";
@@ -46,6 +46,11 @@ export type RpcRequest = {
46
46
  handleId: string | null;
47
47
  method: string;
48
48
  args: unknown[];
49
+ } | {
50
+ version: typeof protocolVersion;
51
+ /** The request whose work should stop. Cancellation has no response frame of its own. */
52
+ requestId: string;
53
+ kind: "rpc-cancel";
49
54
  };
50
55
  export type RpcResponse = {
51
56
  version: typeof protocolVersion;
@@ -1,4 +1,4 @@
1
- export const protocolVersion = 2;
1
+ export const protocolVersion = 3;
2
2
  /** Outstanding request/response pairs retained by either side of one database RPC connection. */
3
3
  export const MAX_DATABASE_RPC_IN_FLIGHT = 256;
4
4
  export function parseRequest(value) {
@@ -64,8 +64,11 @@ export function parseRpcRequest(value) {
64
64
  if (typeof value !== "object" || value === null)
65
65
  return null;
66
66
  const candidate = value;
67
- if (candidate.kind !== "rpc-init" && candidate.kind !== "rpc-call")
67
+ if (candidate.kind !== "rpc-init" &&
68
+ candidate.kind !== "rpc-call" &&
69
+ candidate.kind !== "rpc-cancel") {
68
70
  return null;
71
+ }
69
72
  if (candidate.version !== protocolVersion)
70
73
  throw new Error("Unsupported protocol version");
71
74
  if (typeof candidate.requestId !== "string" || candidate.requestId.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minnowdb/core",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "A columnar SQL database for the browser: PostgreSQL-style SQL over durable IndexedDB or OPFS data, with no server or WebAssembly module.",
5
5
  "license": "MIT",
6
6
  "author": "Eric Wilhite",
@@ -149,6 +149,11 @@
149
149
  "classification": "different",
150
150
  "reason": "Minnow returns arrays as canonical JSON text at the JavaScript boundary while PostgreSQL clients commonly return native arrays."
151
151
  },
152
+ {
153
+ "id": "type.date",
154
+ "classification": "different",
155
+ "reason": "Minnow returns zoneless DATE values as canonical YYYY-MM-DD text while PostgreSQL clients commonly materialize them as midnight Date objects."
156
+ },
152
157
  {
153
158
  "id": "json.query",
154
159
  "classification": "different",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 4,
2
+ "version": 5,
3
3
  "description": "Executable PostgreSQL compatibility examples for MinnowDatabase. postgres-feature-profile.json records differences, extensions, and embedded exclusions.",
4
4
  "features": [
5
5
  {
@@ -20,7 +20,8 @@
20
20
  {
21
21
  "id": "select.distinct",
22
22
  "status": "supported",
23
- "example": "SELECT DISTINCT region FROM rows"
23
+ "example": "SELECT DISTINCT region FROM rows",
24
+ "notes": "Plain projections and SELECT DISTINCT * are supported. DISTINCT combined with grouping, HAVING, or aggregate/window expressions is rejected."
24
25
  },
25
26
  {
26
27
  "id": "select.scalar-subquery",
@@ -127,7 +128,7 @@
127
128
  "id": "group-by.rollup",
128
129
  "status": "supported",
129
130
  "example": "SELECT region, SUM(amount) AS total FROM rows GROUP BY ROLLUP(region)",
130
- "notes": "ROLLUP/CUBE/GROUPING SETS desugar into a UNION ALL of grouped blocks. The GROUPING() marker is deliberately unsupported, so rollup NULLs and data NULLs are indistinguishable. SQLite itself has none of these."
131
+ "notes": "ROLLUP/CUBE/GROUPING SETS desugar into a UNION ALL of grouped blocks. GROUPING() distinguishes rolled-up columns from data NULLs. SQLite itself has none of these."
131
132
  },
132
133
  {
133
134
  "id": "group-by.grouping-sets",
@@ -245,7 +246,7 @@
245
246
  "id": "mutation.update-keyed",
246
247
  "status": "supported",
247
248
  "example": "UPDATE keyed SET score = score + 1 WHERE score > 0",
248
- "notes": "Requires a unique-key table; read-then-mutate, not serializable."
249
+ "notes": "Requires a unique-key table. A standalone statement reads and publishes inside one retryable write scope; an explicit transaction stages it with the transaction's other statements."
249
250
  },
250
251
  {
251
252
  "id": "mutation.delete-keyed",
@@ -316,7 +317,7 @@
316
317
  "id": "predicate.quantified",
317
318
  "status": "supported",
318
319
  "example": "SELECT region FROM rows WHERE amount > ALL (SELECT amount FROM dims)",
319
- "notes": "ANY/SOME/ALL with full three-valued logic; correlated forms are rejected. SQLite itself has no quantified comparisons."
320
+ "notes": "ANY/SOME/ALL use full three-valued logic. Correlated forms are supported as top-level WHERE predicates. SQLite itself has no quantified comparisons."
320
321
  },
321
322
  {
322
323
  "id": "predicate.ilike",
@@ -387,30 +388,67 @@
387
388
  "id": "subquery.correlated",
388
389
  "status": "supported",
389
390
  "example": "SELECT region FROM rows r WHERE amount > (SELECT AVG(amount) FROM rows q WHERE q.region = r.region)",
390
- "notes": "Equality-correlated subqueries decorrelate into derived-table joins at compile time; both executors run plain joins."
391
+ "notes": "Correlated subqueries decorrelate into derived-table joins at compile time; both executors run plain joins."
391
392
  },
392
393
  {
393
394
  "id": "subquery.correlated-exists",
394
395
  "status": "supported",
395
396
  "example": "SELECT amount FROM rows r WHERE EXISTS (SELECT region FROM dims d WHERE d.region = r.region)",
396
- "notes": "EXISTS joins the subquery's distinct correlation keys; NOT EXISTS becomes a left join checked with IS NULL."
397
+ "notes": "Top-level EXISTS and NOT EXISTS lower to semi-joins and anti-joins. Nested boolean forms use hidden per-probe match flags."
397
398
  },
398
399
  {
399
400
  "id": "subquery.correlated-select",
400
401
  "status": "supported",
401
402
  "example": "SELECT r.region, (SELECT AVG(q.amount) FROM rows q WHERE q.region = r.region) AS regional FROM rows r",
402
- "notes": "Correlated scalar aggregates decorrelate in the select list too, outside grouped queries."
403
+ "notes": "Correlated scalar aggregates decorrelate in the select list. In a grouped query, their outer references must be GROUP BY columns and the scalar cannot sit inside an outer aggregate."
404
+ },
405
+ {
406
+ "id": "subquery.correlated-select-grouped",
407
+ "status": "supported",
408
+ "example": "SELECT r.region, COUNT(*) AS c, (SELECT AVG(q.amount) FROM rows q WHERE q.region = r.region) AS regional FROM rows r GROUP BY r.region",
409
+ "notes": "The decorrelated value is functionally determined by the outer grouping keys and is carried as an internal group key."
410
+ },
411
+ {
412
+ "id": "subquery.correlated-scalar-non-equi",
413
+ "status": "supported",
414
+ "example": "SELECT r.amount, (SELECT COUNT(*) FROM rows q WHERE q.amount < r.amount) AS lower_count FROM rows r",
415
+ "notes": "Non-equality scalar aggregates group inner rows once per distinct outer probe tuple, then join the result back by equality."
416
+ },
417
+ {
418
+ "id": "subquery.correlated-exists-expression",
419
+ "status": "supported",
420
+ "example": "SELECT r.amount FROM rows r WHERE r.amount > 100 OR EXISTS (SELECT d.region FROM dims d WHERE d.region = r.region)",
421
+ "notes": "Correlated EXISTS and NOT EXISTS remain set-at-a-time when nested below OR, NOT, or CASE."
403
422
  },
404
423
  {
405
424
  "id": "subquery.correlated-non-equi",
406
425
  "status": "supported",
407
426
  "example": "SELECT r.amount FROM rows r WHERE EXISTS (SELECT q.amount FROM rows q WHERE q.amount < r.amount)",
408
- "notes": "Non-equality EXISTS and NOT EXISTS correlations lower to semi-joins and anti-joins; correlated scalar aggregates and IN remain equality-only."
427
+ "notes": "Non-equality EXISTS and NOT EXISTS correlations lower to semi-joins and anti-joins. Scalar aggregates use distinct outer probes."
428
+ },
429
+ {
430
+ "id": "subquery.correlated-in-non-equi",
431
+ "status": "supported",
432
+ "example": "SELECT r.amount FROM rows r WHERE r.region IN (SELECT q.region FROM rows q WHERE q.amount < r.amount)",
433
+ "notes": "A range-correlated IN predicate lowers to a semi-join carrying both its correlation and membership comparisons."
409
434
  },
410
435
  {
411
436
  "id": "subquery.correlated-not-in",
412
437
  "status": "supported",
413
- "example": "SELECT region FROM rows r WHERE region NOT IN (SELECT d.region FROM dims d WHERE d.region = r.region)"
438
+ "example": "SELECT region FROM rows r WHERE region NOT IN (SELECT d.region FROM dims d WHERE d.region = r.region)",
439
+ "notes": "Correlated NOT IN preserves empty-set and NULL semantics rather than treating it as a simple anti-join."
440
+ },
441
+ {
442
+ "id": "subquery.correlated-not-in-non-equi",
443
+ "status": "supported",
444
+ "example": "SELECT r.amount FROM rows r WHERE 'north' NOT IN (SELECT q.region FROM rows q WHERE q.amount < r.amount)",
445
+ "notes": "Range correlation uses an anti-join for exact matches plus per-probe total and non-NULL counts."
446
+ },
447
+ {
448
+ "id": "subquery.correlated-quantified",
449
+ "status": "supported",
450
+ "example": "SELECT r.amount FROM rows r WHERE r.amount > ALL (SELECT q.amount FROM rows q WHERE q.region = r.region)",
451
+ "notes": "At top-level WHERE, correlated ANY lowers to a semi-join on true comparisons and ALL to an anti-join on false or unknown comparisons."
414
452
  },
415
453
  {
416
454
  "id": "cte.recursive",
@@ -456,13 +494,13 @@
456
494
  "id": "aggregate.filter",
457
495
  "status": "supported",
458
496
  "example": "SELECT region, COUNT(*) FILTER (WHERE amount > 5) AS big FROM rows GROUP BY region",
459
- "notes": "Desugars into a CASE inside the aggregate, so it works with every aggregate and DISTINCT."
497
+ "notes": "Desugars into a CASE inside COUNT/SUM/AVG/MIN/MAX and preserves DISTINCT. JSON_ARRAYAGG does not support FILTER."
460
498
  },
461
499
  {
462
500
  "id": "window.aggregate-over",
463
501
  "status": "supported",
464
502
  "example": "SELECT SUM(amount) OVER (PARTITION BY region) AS total FROM rows",
465
- "notes": "Default frame only: whole partition without OVER ordering, running with peers when ordered."
503
+ "notes": "Without explicit framing, the default is the whole partition when unordered and a peer-aware running frame when ordered. ROWS, RANGE, GROUPS, and exclusions are tracked separately below."
466
504
  },
467
505
  {
468
506
  "id": "window.in-expression",
@@ -569,7 +607,7 @@
569
607
  "id": "trigger.create-after",
570
608
  "status": "supported",
571
609
  "example": "CREATE TRIGGER keyed_audit AFTER INSERT ON keyed BEGIN INSERT INTO rows (region, amount) VALUES (NEW.name, NEW.score); END",
572
- "notes": "AFTER and BEFORE row triggers on INSERT/UPDATE/DELETE, executed atomically inside the triggering commit with NEW/OLD references. Bodies: INSERT ... VALUES into keyless tables; UPDATE/DELETE against keyed tables. One cascade level is allowed; deeper chains error at write time."
610
+ "notes": "AFTER and BEFORE row triggers on INSERT/UPDATE/DELETE execute atomically with NEW/OLD references. Bodies support parameter-free INSERT ... VALUES into keyless tables and UPDATE/DELETE against keyed tables; ON CONFLICT and RETURNING are rejected. One cascade level is allowed."
573
611
  },
574
612
  {
575
613
  "id": "trigger.create-before",
@@ -970,8 +1008,8 @@
970
1008
  {
971
1009
  "id": "json.object",
972
1010
  "status": "supported",
973
- "example": "SELECT JSON_OBJECT('a' VALUE 1, 'missing' VALUE NULL) AS document",
974
- "notes": "Defaults to NULL ON NULL and WITHOUT UNIQUE KEYS. NULL keys are rejected. The constructor returns JSON text; cast or store it as JSON/JSONB for domain validation and JSONB canonicalization."
1011
+ "example": "SELECT JSON_OBJECT('a' VALUE 1, 'detail' VALUE JSON_OBJECT('name' VALUE 'Acme')) AS document",
1012
+ "notes": "Defaults to NULL ON NULL and WITHOUT UNIQUE KEYS. NULL keys are rejected. JSON-producing arguments embed as documents rather than escaped strings; ordinary text remains a string. The constructor returns JSON text; cast or store it as JSON/JSONB for domain validation and JSONB canonicalization."
975
1013
  },
976
1014
  {
977
1015
  "id": "json.array",
@@ -990,6 +1028,12 @@
990
1028
  "example": "CREATE TABLE defaulted (id INTEGER PRIMARY KEY, tier TEXT DEFAULT 'basic')",
991
1029
  "notes": "DEFAULT accepts a variable-free scalar expression. Omission or SQL DEFAULT invokes it; explicit NULL follows the column's independent nullability."
992
1030
  },
1031
+ {
1032
+ "id": "ddl.generated-column",
1033
+ "status": "supported",
1034
+ "example": "CREATE TABLE generated_value (base INTEGER, doubled INTEGER GENERATED ALWAYS AS (base * 2) STORED)",
1035
+ "notes": "Stored generated columns recompute on every write and may be indexed, but cannot be caller-assigned or used as Minnow's row-addressing primary/unique key. Expressions are immutable and row-local."
1036
+ },
993
1037
  {
994
1038
  "id": "ddl.create-table-key-clause",
995
1039
  "status": "supported",
@@ -1117,7 +1161,13 @@
1117
1161
  "id": "from.lateral",
1118
1162
  "status": "supported",
1119
1163
  "example": "SELECT x.amount FROM rows r, LATERAL (SELECT amount FROM dims WHERE dims.region = r.region) x",
1120
- "notes": "Equality-correlated derived sources become set-at-a-time joins. Correlated grouping, ordering, and LIMIT remain unsupported."
1164
+ "notes": "Equality and range-correlated derived sources become set-at-a-time joins. Correlated grouping, ordering, and LIMIT remain unsupported."
1165
+ },
1166
+ {
1167
+ "id": "from.lateral-non-equi",
1168
+ "status": "supported",
1169
+ "example": "SELECT x.amount FROM rows r, LATERAL (SELECT q.amount FROM rows q WHERE q.amount < r.amount) x",
1170
+ "notes": "Non-equality correlation lowers to an ordinary general join rather than executing the derived source once per outer row."
1121
1171
  },
1122
1172
  {
1123
1173
  "id": "aggregate.string-agg",
@@ -1146,8 +1196,8 @@
1146
1196
  {
1147
1197
  "id": "aggregate.json",
1148
1198
  "status": "supported",
1149
- "example": "SELECT JSON_ARRAYAGG(region) AS regions FROM rows",
1150
- "notes": "Supports DISTINCT, includes SQL NULL as JSON null, and returns NULL for empty input. FILTER, window use, aggregate-local ORDER BY, and explicit NULL/ABSENT clauses are not supported; input order is otherwise unspecified."
1199
+ "example": "SELECT JSON_ARRAYAGG(JSON_OBJECT('region' VALUE region)) AS regions FROM rows",
1200
+ "notes": "Supports DISTINCT, embeds JSON-producing inputs as documents, includes SQL NULL as JSON null, and returns NULL for empty input. FILTER, window use, aggregate-local ORDER BY, and explicit NULL/ABSENT clauses are not supported; input order is otherwise unspecified."
1151
1201
  },
1152
1202
  {
1153
1203
  "id": "type.array",
@@ -1161,6 +1211,12 @@
1161
1211
  "example": "SELECT TIME '12:00:00' AS at",
1162
1212
  "notes": "A time of day without a time zone, returned as canonical text."
1163
1213
  },
1214
+ {
1215
+ "id": "type.date",
1216
+ "status": "supported",
1217
+ "example": "SELECT CAST('2026-08-26' AS DATE) AS day",
1218
+ "notes": "A calendar date without a time zone, returned as canonical YYYY-MM-DD text."
1219
+ },
1164
1220
  {
1165
1221
  "id": "ddl.sequence",
1166
1222
  "status": "supported",
@@ -1178,7 +1234,7 @@
1178
1234
  "id": "ddl.create-view",
1179
1235
  "status": "supported",
1180
1236
  "example": "CREATE VIEW west AS SELECT region, amount FROM rows WHERE region = 'west'",
1181
- "notes": "The catalog stores the query text and the schema inferred from it, so a view answers the same questions a table does and reads expand it into the query it stands for — anywhere a read runs, including inside a write scope. A view is never a write target. CREATE OR REPLACE redefines one; a view stacked on it follows the new definition, and a cycle two redefinitions close is caught on the next read rather than recursed."
1237
+ "notes": "The catalog stores the query text and inferred schema, so reads expand a view anywhere a table can be read, including inside a write scope. A view is never a write target. CREATE OR REPLACE redefines one; dependent views follow it and cycles are rejected on read. CREATE VIEW column-name lists are not supported."
1182
1238
  },
1183
1239
  {
1184
1240
  "id": "ddl.drop-view",