orez 0.4.73 → 0.5.2

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 (73) hide show
  1. package/README.md +42 -1
  2. package/dist/cf-do/cdc.d.ts +148 -0
  3. package/dist/cf-do/cdc.d.ts.map +1 -0
  4. package/dist/cf-do/cdc.js +719 -0
  5. package/dist/cf-do/cdc.js.map +1 -0
  6. package/dist/cf-do/row-undo.d.ts +36 -0
  7. package/dist/cf-do/row-undo.d.ts.map +1 -0
  8. package/dist/cf-do/row-undo.js +209 -0
  9. package/dist/cf-do/row-undo.js.map +1 -0
  10. package/dist/cf-do/tx-journal.d.ts +32 -10
  11. package/dist/cf-do/tx-journal.d.ts.map +1 -1
  12. package/dist/cf-do/tx-journal.js +148 -31
  13. package/dist/cf-do/tx-journal.js.map +1 -1
  14. package/dist/cf-do/watermark.d.ts +7 -0
  15. package/dist/cf-do/watermark.d.ts.map +1 -1
  16. package/dist/cf-do/watermark.js +9 -0
  17. package/dist/cf-do/watermark.js.map +1 -1
  18. package/dist/cf-do/worker.d.ts +19 -1
  19. package/dist/cf-do/worker.d.ts.map +1 -1
  20. package/dist/cf-do/worker.js +218 -46
  21. package/dist/cf-do/worker.js.map +1 -1
  22. package/dist/cli.d.ts.map +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/cli.js.map +1 -1
  25. package/dist/config.d.ts +35 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +1 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/do-sql-tracking.d.ts +2 -0
  30. package/dist/do-sql-tracking.d.ts.map +1 -1
  31. package/dist/do-sql-tracking.js +11 -1
  32. package/dist/do-sql-tracking.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +146 -40
  36. package/dist/index.js.map +1 -1
  37. package/dist/pg-proxy-do-backend.d.ts +8 -0
  38. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  39. package/dist/pg-proxy-do-backend.js +301 -47
  40. package/dist/pg-proxy-do-backend.js.map +1 -1
  41. package/dist/pg-proxy.d.ts +22 -1
  42. package/dist/pg-proxy.d.ts.map +1 -1
  43. package/dist/pg-proxy.js +63 -1
  44. package/dist/pg-proxy.js.map +1 -1
  45. package/dist/pg-sqlite-compiler/index.d.ts.map +1 -1
  46. package/dist/pg-sqlite-compiler/index.js +9 -1
  47. package/dist/pg-sqlite-compiler/index.js.map +1 -1
  48. package/dist/pg-sqlite-compiler/passes/datetime.d.ts +2 -0
  49. package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -1
  50. package/dist/pg-sqlite-compiler/passes/datetime.js +20 -18
  51. package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -1
  52. package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -1
  53. package/dist/pg-sqlite-compiler/passes/index.js +6 -1
  54. package/dist/pg-sqlite-compiler/passes/index.js.map +1 -1
  55. package/dist/pg-sqlite-compiler/passes/json-functions.d.ts +3 -0
  56. package/dist/pg-sqlite-compiler/passes/json-functions.d.ts.map +1 -0
  57. package/dist/pg-sqlite-compiler/passes/json-functions.js +57 -0
  58. package/dist/pg-sqlite-compiler/passes/json-functions.js.map +1 -0
  59. package/dist/pg-sqlite-compiler/passes/row-json.d.ts +3 -0
  60. package/dist/pg-sqlite-compiler/passes/row-json.d.ts.map +1 -0
  61. package/dist/pg-sqlite-compiler/passes/row-json.js +230 -0
  62. package/dist/pg-sqlite-compiler/passes/row-json.js.map +1 -0
  63. package/dist/pg-sqlite-compiler/passes/schema.d.ts.map +1 -1
  64. package/dist/pg-sqlite-compiler/passes/schema.js +16 -1
  65. package/dist/pg-sqlite-compiler/passes/schema.js.map +1 -1
  66. package/dist/pg-sqlite-compiler/passes/string-functions.d.ts +11 -0
  67. package/dist/pg-sqlite-compiler/passes/string-functions.d.ts.map +1 -0
  68. package/dist/pg-sqlite-compiler/passes/string-functions.js +37 -0
  69. package/dist/pg-sqlite-compiler/passes/string-functions.js.map +1 -0
  70. package/dist/worker/local-sql-backend.d.ts.map +1 -1
  71. package/dist/worker/local-sql-backend.js +114 -13
  72. package/dist/worker/local-sql-backend.js.map +1 -1
  73. package/package.json +2 -2
@@ -1,9 +1,10 @@
1
1
  // @ts-nocheck
2
2
  import { deparseSync, loadModule, parseSync } from 'pgsql-parser';
3
3
  import { TX_MANIFEST_DDL, TX_MANIFEST_TABLE } from './cf-do/tx-journal.js';
4
- import { RETURNING_INTERNAL_PREFIX } from './do-sql-tracking.js';
4
+ import { isSqlRowMutation, RETURNING_INTERNAL_PREFIX } from './do-sql-tracking.js';
5
5
  import { expandDelete, FkCascadeRegistry, recordAlterTableForeignKeys, recordCreateTableForeignKeys, } from './fk-cascade.js';
6
6
  import { Mutex } from './mutex.js';
7
+ import { compile as compilePgToSqlite } from './pg-sqlite-compiler/index.js';
7
8
  import { foldCountMarkerResult, transformCountedDeleteCte, } from './pg-sqlite-compiler/passes/dml-cte.js';
8
9
  import { signalReplicationChange } from './replication/handler.js';
9
10
  import { markSQLiteKeywordIdentifiers, restoreSQLiteKeywordIdentifierMarkers, } from './sqlite-keyword-identifiers.js';
@@ -13,7 +14,8 @@ import { markSQLiteKeywordIdentifiers, restoreSQLiteKeywordIdentifierMarkers, }
13
14
  * Translates PG wire protocol messages → SQL → DO HTTP API → PG wire protocol responses.
14
15
  *
15
16
  * Handles PG transactions transparently: BEGIN/COMMIT/ROLLBACK are intercepted
16
- * and data writes are restored from table snapshots on ROLLBACK.
17
+ * and data writes are restored from row before-images on ROLLBACK, with table
18
+ * snapshots retained as a fallback for writes the SQL planner cannot identify.
17
19
  */
18
20
  const textEncoder = new TextEncoder();
19
21
  const textDecoder = new TextDecoder();
@@ -53,6 +55,22 @@ const METADATA_TABLE = '_orez_pg_metadata';
53
55
  // publication picks it up quickly, long enough that a genuinely
54
56
  // publication-less db doesn't re-query on every write.
55
57
  const EMPTY_PUBLICATION_RELOAD_THROTTLE_MS = 1000;
58
+ // All DoBackend instances for one logical database share a durable trigger
59
+ // set. Keep one ordered schema-scan/registration queue per target in this
60
+ // isolate; DDL/publication changes invalidate the reusable result while
61
+ // preserving request order.
62
+ const cdcRegistrationSyncs = new Map();
63
+ function cdcRegistrationState(key) {
64
+ let state = cdcRegistrationSyncs.get(key);
65
+ if (!state) {
66
+ state = { current: null, tail: Promise.resolve() };
67
+ cdcRegistrationSyncs.set(key, state);
68
+ }
69
+ return state;
70
+ }
71
+ function hasCdcRegistrationSync(key) {
72
+ return !!cdcRegistrationSyncs.get(key)?.current;
73
+ }
56
74
  // ── Utilities ─────────────────────────────────────────────────────────────
57
75
  function concat(...parts) {
58
76
  let total = 0;
@@ -459,7 +477,9 @@ function inferFieldsFromSQL(sql) {
459
477
  : extractTopLevelSelectList(sql);
460
478
  if (!list || list === '*')
461
479
  return [];
462
- return splitTopLevelComma(list).map((part) => ({ name: inferFieldName(part) }));
480
+ return splitTopLevelComma(list).map((part) => ({
481
+ name: inferFieldName(part),
482
+ }));
463
483
  }
464
484
  function columnRefTailName(value) {
465
485
  const fields = value?.ColumnRef?.fields;
@@ -1140,9 +1160,6 @@ function isDollarQuoteTagStart(ch) {
1140
1160
  function isDollarQuoteTagPart(ch) {
1141
1161
  return ch === '_' || isAsciiAlpha(ch) || isAsciiDigit(ch);
1142
1162
  }
1143
- function currentTimestampNode() {
1144
- return { SQLValueFunction: { op: 'SVFOP_CURRENT_TIMESTAMP', typmod: -1 } };
1145
- }
1146
1163
  function isTimestampOid(oid) {
1147
1164
  return oid === PG_TYPE_TIMESTAMP || oid === PG_TYPE_TIMESTAMPTZ;
1148
1165
  }
@@ -2005,8 +2022,6 @@ function rewriteNode(value, context) {
2005
2022
  const sqliteName = SQLITE_FUNCTION_BY_PG_FUNCTION.get(name ?? '');
2006
2023
  if (sqliteName)
2007
2024
  value.FuncCall.funcname = [stringNode(sqliteName)];
2008
- if (name === 'now')
2009
- return currentTimestampNode();
2010
2025
  if (name === 'nextval')
2011
2026
  return intConst(1);
2012
2027
  if (name === '_drop_zero_slot' || name === 'pg_drop_replication_slot') {
@@ -2600,7 +2615,9 @@ function normalizeRename(stmt) {
2600
2615
  flattenRangeVar(relation);
2601
2616
  if (to.table !== stmt.newname)
2602
2617
  stmt.newname = to.table;
2603
- return { schemaMetadataChanges: [{ action: 'renameTable', from: table, to }] };
2618
+ return {
2619
+ schemaMetadataChanges: [{ action: 'renameTable', from: table, to }],
2620
+ };
2604
2621
  }
2605
2622
  if (stmt.renameType === 'OBJECT_COLUMN' && stmt.subname && stmt.newname) {
2606
2623
  flattenRangeVar(relation);
@@ -2805,7 +2822,12 @@ function replaceSchemaSpecsFunctionCalls(sql) {
2805
2822
  }
2806
2823
  function deparseStatement(version, stmt) {
2807
2824
  const quotedByMarker = markSQLiteKeywordIdentifiers(stmt);
2808
- return stripTrailingSemicolon(restoreSQLiteKeywordIdentifierMarkers(deparseSync({ version, stmts: [{ stmt }] }), quotedByMarker).trim());
2825
+ const proxySql = stripTrailingSemicolon(restoreSQLiteKeywordIdentifierMarkers(deparseSync({ version, stmts: [{ stmt }] }), quotedByMarker).trim());
2826
+ // The proxy owns transport-specific metadata and execution planning, but
2827
+ // pg-to-sqlite is the final authority for PostgreSQL SQL semantics. Running
2828
+ // the deparsed statement through the package compiler here prevents the
2829
+ // proxy from growing a second set of function/type/syntax rewrites.
2830
+ return compilePgToSqlite(proxySql).sql;
2809
2831
  }
2810
2832
  function starTarget() {
2811
2833
  return {
@@ -3065,7 +3087,9 @@ function compileSelectIntoNew(version, select, triggerTable, rowCondition) {
3065
3087
  const targetColumn = rel.relname;
3066
3088
  const cloned = cloneAst(select);
3067
3089
  delete cloned.intoClause;
3068
- const selectSQL = deparseStatement(version, { SelectStmt: rewriteNode(cloned) });
3090
+ const selectSQL = deparseStatement(version, {
3091
+ SelectStmt: rewriteNode(cloned),
3092
+ });
3069
3093
  return rowUpdateSQL(triggerTable, targetColumn, `(${selectSQL})`, rowCondition);
3070
3094
  }
3071
3095
  function deparseExpressionSQL(version, expr) {
@@ -3865,7 +3889,9 @@ function copySelectSQL(sql) {
3865
3889
  stringValue(def.arg)?.toLowerCase() === 'binary');
3866
3890
  });
3867
3891
  return {
3868
- sql: deparseStatement(parsed.version, { SelectStmt: copy.query.SelectStmt }),
3892
+ sql: deparseStatement(parsed.version, {
3893
+ SelectStmt: copy.query.SelectStmt,
3894
+ }),
3869
3895
  binary,
3870
3896
  };
3871
3897
  }
@@ -4673,7 +4699,10 @@ function advisoryLockResult(select) {
4673
4699
  : [{ name: 'pg_advisory_xact_lock', source: 'pg_advisory_xact_lock' }];
4674
4700
  return {
4675
4701
  rows: [Object.fromEntries(projectedFields.map((field) => [field.name, null]))],
4676
- fields: projectedFields.map((field) => ({ name: field.name, oid: field.oid })),
4702
+ fields: projectedFields.map((field) => ({
4703
+ name: field.name,
4704
+ oid: field.oid,
4705
+ })),
4677
4706
  };
4678
4707
  }
4679
4708
  function logicalEmitMessageResult(select) {
@@ -4741,14 +4770,15 @@ export class DoBackend {
4741
4770
  // connects past the client's connect budget. cache per instance, invalidated
4742
4771
  // on DDL / metadata / publication changes and on rollback.
4743
4772
  publicationTableInfoCache = null;
4773
+ cdcRegistrationDirty = false;
4744
4774
  readyPromise = null;
4745
4775
  operationMutex = new Mutex();
4746
4776
  // Transaction state. The Durable Object refuses raw SQL BEGIN/COMMIT/SAVEPOINT
4747
4777
  // (Cloudflare requires ctx.storage.transaction()), so PG-style multi-call
4748
4778
  // transactions can't ride on SQLite's own transaction machinery. Instead we
4749
- // emulate rollback by snapshotting each table on its first in-tx write and
4750
- // restoring it atomically through /batch on ROLLBACK. Atomicity of the
4751
- // restore itself comes from the DO's /batch transaction.
4779
+ // emulate rollback with CDC row before-images for parsed DML. Unknown writes
4780
+ // fall back to snapshotting the table on its first in-tx write. Both restore
4781
+ // paths run atomically in the DO's storage transaction.
4752
4782
  inTransaction = false;
4753
4783
  txID = null;
4754
4784
  txSnapshot = null;
@@ -5131,6 +5161,7 @@ export class DoBackend {
5131
5161
  // Cached rewrites may reference metadata that the rollback just invalidated.
5132
5162
  this.rewriteCache.clear();
5133
5163
  this.publicationTableInfoCache = null;
5164
+ this.invalidateCdcRegistration();
5134
5165
  }
5135
5166
  clearTransactionState() {
5136
5167
  this.inTransaction = false;
@@ -5170,14 +5201,16 @@ export class DoBackend {
5170
5201
  const shouldSignal = this.txHasTrackedWrite;
5171
5202
  const txID = this.txID;
5172
5203
  // ONE atomic commit point on the backend: promotes pending tracked
5173
- // changes and clears the tx journal (snapshots + manifest) in a single
5204
+ // changes and clears the tx journal (row images, snapshots, and manifest) in a single
5174
5205
  // storage transaction. read-only transactions skip the round-trip.
5175
5206
  if (txID && (shouldSignal || this.txDataSnapshots.size > 0)) {
5176
5207
  await this.httpClient.post(this.url('/commit-tx'), JSON.stringify({ transactionID: txID }), { 'Content-Type': 'application/json' });
5177
5208
  }
5178
5209
  this.clearTransactionState();
5179
- if (shouldPersist)
5210
+ if (shouldPersist) {
5180
5211
  await this.persistDurableMetadata();
5212
+ await this.ensureCdcRegistration();
5213
+ }
5181
5214
  if (shouldSignal)
5182
5215
  signalReplicationChange();
5183
5216
  }
@@ -5189,8 +5222,8 @@ export class DoBackend {
5189
5222
  const snapshot = this.txSnapshot;
5190
5223
  const txID = this.txID;
5191
5224
  try {
5192
- // ONE atomic rollback on the backend: restores snapshotted tables from
5193
- // the durable tx journal and discards pending tracked changes.
5225
+ // ONE atomic rollback on the backend: restores row before-images and any
5226
+ // fallback table snapshots, then discards pending tracked changes.
5194
5227
  if (txID && (this.txHasTrackedWrite || this.txDataSnapshots.size > 0)) {
5195
5228
  await this.httpClient.post(this.url('/rollback-tx'), JSON.stringify({ transactionID: txID }), { 'Content-Type': 'application/json' });
5196
5229
  }
@@ -5200,6 +5233,8 @@ export class DoBackend {
5200
5233
  this.restoreTransactionMetadataSnapshot(snapshot);
5201
5234
  await this.persistDurableMetadata();
5202
5235
  this.clearTransactionState();
5236
+ if (snapshot)
5237
+ await this.ensureCdcRegistration();
5203
5238
  }
5204
5239
  }
5205
5240
  async execProtocolRaw(message, options) {
@@ -5325,7 +5360,13 @@ export class DoBackend {
5325
5360
  return this.buildCopyResponse(result, copySelect.sql, copySelect.binary);
5326
5361
  }
5327
5362
  // Prepare query
5328
- const rewrittenStatements = this.rewriteSQLStatements(sql);
5363
+ let rewrittenStatements;
5364
+ try {
5365
+ rewrittenStatements = this.rewriteSQLStatements(sql);
5366
+ }
5367
+ catch (err) {
5368
+ return concat(buildErrorResponse(err.message), this.readyForQuery());
5369
+ }
5329
5370
  const rewritten = rewrittenSQLText(rewrittenStatements);
5330
5371
  if (rewritten === '' || rewritten.startsWith('--')) {
5331
5372
  await this.applyStatementMetadata(rewrittenStatements);
@@ -5357,6 +5398,13 @@ export class DoBackend {
5357
5398
  }
5358
5399
  async applyStatementMetadata(statements) {
5359
5400
  let changed = false;
5401
+ let publicationChanged = false;
5402
+ if (statements.some((statement) => statement.isDDL)) {
5403
+ // DROP/CREATE INDEX and DROP TABLE may not carry column metadata deltas,
5404
+ // but they still invalidate both the catalog and persisted trigger set.
5405
+ this.publicationTableInfoCache = null;
5406
+ this.invalidateCdcRegistration();
5407
+ }
5360
5408
  for (const statement of statements) {
5361
5409
  for (const column of statement.schemaColumns ?? []) {
5362
5410
  let table = this.schemaMetadata.get(column.table);
@@ -5374,6 +5422,7 @@ export class DoBackend {
5374
5422
  for (const change of statement.publicationChanges ?? []) {
5375
5423
  this.applyPublicationChange(change);
5376
5424
  changed = true;
5425
+ publicationChanged = true;
5377
5426
  }
5378
5427
  if (statement.fkEdges) {
5379
5428
  // edges were already added to this.fkRegistry at rewrite time; persist
@@ -5385,6 +5434,7 @@ export class DoBackend {
5385
5434
  }
5386
5435
  if (changed) {
5387
5436
  this.publicationTableInfoCache = null;
5437
+ this.invalidateCdcRegistration();
5388
5438
  // inside an explicit tx we batch the persist to commit time. chat's
5389
5439
  // migrations open one tx and run dozens of DDL statements; persisting
5390
5440
  // after every one was the hot path.
@@ -5392,6 +5442,12 @@ export class DoBackend {
5392
5442
  this.txMetadataDirty = true;
5393
5443
  else
5394
5444
  await this.persistDurableMetadata();
5445
+ // Publication membership changes alter which tables may enter the
5446
+ // changefeed. Apply those immediately; ordinary schema changes are
5447
+ // refreshed lazily before the next row write to keep migrations fast.
5448
+ if (publicationChanged && !this.inTransaction) {
5449
+ await this.ensureCdcRegistration();
5450
+ }
5395
5451
  }
5396
5452
  }
5397
5453
  applySchemaMetadataChange(change) {
@@ -5466,36 +5522,121 @@ export class DoBackend {
5466
5522
  }
5467
5523
  trackingForStatement(statement) {
5468
5524
  const tracking = statement.changeTracking;
5469
- if (!tracking || this.dbName !== 'postgres')
5525
+ if (!tracking)
5470
5526
  return undefined;
5527
+ // zero-cache's CVR/CDB databases use the same emulated PG transactions as
5528
+ // the application database. Their parsed DML needs rollback row images too;
5529
+ // otherwise hot internal tables such as `<shard>/cdc_changeLog` fall back to
5530
+ // a full table copy on every transaction and produce quadratic writes.
5531
+ if (this.dbName !== 'postgres') {
5532
+ return this.inTransaction ? { ...tracking, publish: false } : undefined;
5533
+ }
5471
5534
  const { table } = tracking;
5535
+ let published = false;
5472
5536
  if (table.schema === 'public') {
5473
5537
  const publications = this.publicationNames();
5474
- if (!publications.length)
5475
- return undefined;
5476
- return this.publicationsForTable(table, publications).length ? tracking : undefined;
5538
+ published =
5539
+ publications.length > 0 &&
5540
+ this.publicationsForTable(table, publications).length > 0;
5477
5541
  }
5478
- if (table.schema !== 'pg_catalog' &&
5542
+ else if (table.schema !== 'pg_catalog' &&
5479
5543
  table.schema !== 'information_schema' &&
5480
5544
  !table.schema.startsWith('pg_') &&
5481
5545
  !table.schema.startsWith('zero_') &&
5482
5546
  !table.schema.startsWith('_zero') &&
5483
5547
  !table.schema.includes('/') &&
5484
5548
  TRACKED_SHARD_TABLES.has(table.tableName)) {
5485
- return tracking;
5549
+ published = true;
5486
5550
  }
5487
- return undefined;
5551
+ if (published)
5552
+ return { ...tracking, publish: true };
5553
+ // PostgreSQL transactions still need row-accurate undo for private and
5554
+ // internal tables. The worker captures the same full before/after images
5555
+ // but marks them rollback-only so they never enter `_zero_changes`.
5556
+ return this.inTransaction ? { ...tracking, publish: false } : undefined;
5488
5557
  }
5489
5558
  trackingRequest(tracking) {
5490
5559
  const rowColumns = this.schemaMetadata.get(tracking.table.table);
5491
5560
  return {
5492
5561
  tableName: `${tracking.table.schema}.${tracking.table.tableName}`,
5562
+ physicalTableName: tracking.table.table,
5493
5563
  operation: tracking.operation,
5494
5564
  returnRows: tracking.returnRows,
5495
5565
  ...(rowColumns ? { rowColumns: [...rowColumns.keys()] } : null),
5496
5566
  ...(this.inTransaction ? { transactionID: this.currentTransactionID() } : null),
5567
+ ...(tracking.publish === false ? { publish: false } : null),
5568
+ };
5569
+ }
5570
+ cdcRegistrationKey() {
5571
+ return this.url('/cdc-registration');
5572
+ }
5573
+ invalidateCdcRegistration() {
5574
+ this.cdcRegistrationDirty = true;
5575
+ const state = cdcRegistrationSyncs.get(this.cdcRegistrationKey());
5576
+ if (state)
5577
+ state.current = null;
5578
+ }
5579
+ cdcRegistrationForInfo(info) {
5580
+ const published = info.schema === 'public' &&
5581
+ this.publicationsForTable(info, this.publicationNames()).length > 0;
5582
+ const trackedShard = info.schema !== 'public' &&
5583
+ info.schema !== 'pg_catalog' &&
5584
+ info.schema !== 'information_schema' &&
5585
+ !info.schema.startsWith('pg_') &&
5586
+ !info.schema.startsWith('zero_') &&
5587
+ !info.schema.startsWith('_zero') &&
5588
+ !info.schema.includes('/') &&
5589
+ TRACKED_SHARD_TABLES.has(info.tableName);
5590
+ if (!published && !trackedShard)
5591
+ return null;
5592
+ return {
5593
+ physicalTableName: info.name,
5594
+ tableName: `${info.schema}.${info.tableName}`,
5497
5595
  };
5498
5596
  }
5597
+ async ensureCdcRegistration() {
5598
+ if (this.dbName !== 'postgres')
5599
+ return;
5600
+ const key = this.cdcRegistrationKey();
5601
+ const state = cdcRegistrationState(key);
5602
+ for (;;) {
5603
+ const forced = this.cdcRegistrationDirty;
5604
+ let pending = state.current;
5605
+ if (forced || !pending) {
5606
+ // Mark this instance's invalidation as scheduled before yielding. If
5607
+ // another invalidation arrives while the request is in flight it sets
5608
+ // the flag again, and the loop queues a fresh authoritative set.
5609
+ this.cdcRegistrationDirty = false;
5610
+ const previous = state.tail;
5611
+ pending = previous
5612
+ .catch(() => undefined)
5613
+ .then(async () => {
5614
+ const infos = await this.publicationTableInfos();
5615
+ const cdcTables = infos
5616
+ .map((info) => this.cdcRegistrationForInfo(info))
5617
+ .filter((registration) => !!registration);
5618
+ await this.httpClient.post(this.url('/batch'), JSON.stringify({ statements: [], cdcTables }), { 'Content-Type': 'application/json' });
5619
+ });
5620
+ state.tail = pending;
5621
+ state.current = pending;
5622
+ }
5623
+ try {
5624
+ await pending;
5625
+ }
5626
+ catch (error) {
5627
+ if (state.current === pending)
5628
+ state.current = null;
5629
+ if (forced)
5630
+ this.cdcRegistrationDirty = true;
5631
+ throw error;
5632
+ }
5633
+ // A different backend may have invalidated or queued a replacement
5634
+ // while this request was in flight. Do not let a caller write until the
5635
+ // newest known registration has also landed.
5636
+ if (state.current === pending && !this.cdcRegistrationDirty)
5637
+ return;
5638
+ }
5639
+ }
5499
5640
  visibleResultForTracking(result, tracking) {
5500
5641
  if (!tracking)
5501
5642
  return result;
@@ -5614,6 +5755,12 @@ export class DoBackend {
5614
5755
  const portal = this.portals.get(portalName);
5615
5756
  if (!portal)
5616
5757
  return buildErrorResponse(`portal "${portalName}" does not exist`);
5758
+ try {
5759
+ this.rejectTransactionalDDL(portal.rewrittenStatements ?? []);
5760
+ }
5761
+ catch (err) {
5762
+ return buildErrorResponse(err.message);
5763
+ }
5617
5764
  if (!portal.sql?.trim()) {
5618
5765
  await this.applyStatementMetadata([
5619
5766
  {
@@ -5915,14 +6062,28 @@ export class DoBackend {
5915
6062
  const arrayParamNumbers = new Set();
5916
6063
  const jsonParamNumbers = new Set();
5917
6064
  const epochMillisParamNumbers = new Set();
6065
+ // DDL rewriting mutates function/FK metadata before execution and DDL
6066
+ // execution invalidates live caches. While an emulated transaction is
6067
+ // active, rewrite against isolated state so rejecting DDL cannot itself
6068
+ // leak a metadata or cache side effect.
6069
+ const skippedFunctionNames = this.inTransaction
6070
+ ? new Set(this.skippedFunctionNames)
6071
+ : this.skippedFunctionNames;
6072
+ const triggerFunctions = this.inTransaction
6073
+ ? this.cloneTriggerFunctions(this.triggerFunctions)
6074
+ : this.triggerFunctions;
6075
+ const fkRegistry = this.inTransaction
6076
+ ? this.cloneFkCascadeRegistry()
6077
+ : this.fkRegistry;
5918
6078
  const statements = rewriteSQLStatements(sql, {
5919
- skippedFunctionNames: this.skippedFunctionNames,
5920
- triggerFunctions: this.triggerFunctions,
6079
+ skippedFunctionNames,
6080
+ triggerFunctions,
5921
6081
  arrayParamNumbers,
5922
6082
  jsonParamNumbers,
5923
6083
  epochMillisParamNumbers,
5924
- fkRegistry: this.fkRegistry,
6084
+ fkRegistry,
5925
6085
  });
6086
+ this.rejectTransactionalDDL(statements);
5926
6087
  if (this.canCacheRewrite(statements)) {
5927
6088
  this.rememberRewrite(key, statements);
5928
6089
  }
@@ -5931,6 +6092,22 @@ export class DoBackend {
5931
6092
  }
5932
6093
  return statements;
5933
6094
  }
6095
+ cloneFkCascadeRegistry() {
6096
+ const cloned = new FkCascadeRegistry();
6097
+ for (const [parent, child] of this.fkRegistry.entries()) {
6098
+ cloned.add(parent, {
6099
+ ...child,
6100
+ columns: [...child.columns],
6101
+ refColumns: [...child.refColumns],
6102
+ });
6103
+ }
6104
+ return cloned;
6105
+ }
6106
+ rejectTransactionalDDL(statements) {
6107
+ if (this.inTransaction && statements.some((statement) => statement.isDDL)) {
6108
+ throw new Error('DDL statements are not supported inside emulated transactions');
6109
+ }
6110
+ }
5934
6111
  canCacheRewrite(statements) {
5935
6112
  return (statements.length > 0 &&
5936
6113
  statements.every((statement) => statement.sql.trim() &&
@@ -6002,6 +6179,14 @@ export class DoBackend {
6002
6179
  async doExecResult(sql, params, track) {
6003
6180
  if (!sql.trim())
6004
6181
  return { rows: [], columns: [] };
6182
+ if (this.dbName === 'postgres' &&
6183
+ (track ||
6184
+ (this.ready &&
6185
+ isSqlRowMutation(sql) &&
6186
+ (this.cdcRegistrationDirty ||
6187
+ !hasCdcRegistrationSync(this.cdcRegistrationKey()))))) {
6188
+ await this.ensureCdcRegistration();
6189
+ }
6005
6190
  const execSQL = sql;
6006
6191
  let lastErr;
6007
6192
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -6010,6 +6195,9 @@ export class DoBackend {
6010
6195
  sql: execSQL,
6011
6196
  ...(params?.length ? { params } : null),
6012
6197
  ...(track ? { track } : null),
6198
+ ...(this.inTransaction
6199
+ ? { transactionID: this.currentTransactionID() }
6200
+ : null),
6013
6201
  }), { 'Content-Type': 'application/json' });
6014
6202
  const result = JSON.parse(resp);
6015
6203
  const rows = (result.rows ?? result ?? []);
@@ -6025,9 +6213,18 @@ export class DoBackend {
6025
6213
  if (counted) {
6026
6214
  return { ...counted, affectedRows: rows.length };
6027
6215
  }
6028
- if (track)
6216
+ const hasCaptureCount = Object.hasOwn(result, 'capturedChanges');
6217
+ const capturedChanges = Number(result.capturedChanges ?? 0);
6218
+ if (capturedChanges > 0 ||
6219
+ (track && track.publish !== false && !hasCaptureCount)) {
6029
6220
  this.signalTrackedWrite();
6030
- return { rows, columns, affectedRows: result.affectedRows };
6221
+ }
6222
+ return {
6223
+ rows,
6224
+ columns,
6225
+ affectedRows: result.affectedRows,
6226
+ capturedChanges,
6227
+ };
6031
6228
  }
6032
6229
  catch (err) {
6033
6230
  lastErr = err;
@@ -6077,12 +6274,34 @@ export class DoBackend {
6077
6274
  await this.doRawBatch(statements);
6078
6275
  this.txDataSnapshots.set(table, snapshot);
6079
6276
  }
6277
+ async journalTransactionTable(table) {
6278
+ if (!this.inTransaction || this.txDataSnapshots.has(table))
6279
+ return;
6280
+ const txID = this.currentTransactionID();
6281
+ // CDC stores full before/after row images for tracked writes. A manifest
6282
+ // row with an empty snapshot marks the transaction as recoverable without
6283
+ // copying the entire table on every short application transaction.
6284
+ await this.doRawBatch([
6285
+ { sql: TX_MANIFEST_DDL },
6286
+ {
6287
+ sql: `INSERT INTO "${TX_MANIFEST_TABLE}" (tx_id, owner, original, snapshot) VALUES (?, ?, ?, ?)`,
6288
+ params: [txID, this.txOwner, table, ''],
6289
+ },
6290
+ ]);
6291
+ this.txDataSnapshots.set(table, '');
6292
+ }
6080
6293
  async snapshotTransactionWrite(statement) {
6081
6294
  if (!this.inTransaction || !statement.isWrite)
6082
6295
  return;
6083
6296
  const table = statement.writeTable?.table;
6084
- if (table)
6085
- await this.snapshotTransactionTable(table);
6297
+ if (!table)
6298
+ return;
6299
+ if (this.trackingForStatement(statement)) {
6300
+ await this.ensureCdcRegistration();
6301
+ await this.journalTransactionTable(table);
6302
+ return;
6303
+ }
6304
+ await this.snapshotTransactionTable(table);
6086
6305
  }
6087
6306
  async doRawBatch(statements) {
6088
6307
  const sqls = statements.filter((statement) => (typeof statement === 'string' ? statement : statement.sql).trim());
@@ -6126,12 +6345,15 @@ export class DoBackend {
6126
6345
  return false;
6127
6346
  }
6128
6347
  async executeRewrittenStatement(statement) {
6348
+ this.rejectTransactionalDDL([statement]);
6129
6349
  if (!statement.sql.trim())
6130
6350
  return { rows: [], columns: [] };
6131
6351
  if (await this.shouldSkipStatement(statement))
6132
6352
  return { rows: [], columns: [] };
6133
- if (statement.isDDL)
6353
+ if (statement.isDDL) {
6134
6354
  this.publicationTableInfoCache = null;
6355
+ this.invalidateCdcRegistration();
6356
+ }
6135
6357
  await this.snapshotTransactionWrite(statement);
6136
6358
  if (statement.cascadeStatements?.length) {
6137
6359
  await this.runCascadeStatements(statement.cascadeStatements, {});
@@ -6170,11 +6392,30 @@ export class DoBackend {
6170
6392
  if (sqls.length === 0)
6171
6393
  return;
6172
6394
  const hasTrackedWrite = sqls.some((statement) => typeof statement !== 'string' && Boolean(statement.track));
6173
- await this.httpClient.post(this.url('/batch'), JSON.stringify({ statements: sqls }), {
6174
- 'Content-Type': 'application/json',
6175
- });
6176
- if (hasTrackedWrite)
6395
+ const hasPublishedTrackedWrite = sqls.some((statement) => typeof statement !== 'string' &&
6396
+ Boolean(statement.track) &&
6397
+ statement.track?.publish !== false);
6398
+ const hasRowWrite = sqls.some((statement) => isSqlRowMutation(typeof statement === 'string' ? statement : statement.sql));
6399
+ if (this.dbName === 'postgres' &&
6400
+ (hasTrackedWrite ||
6401
+ (hasRowWrite &&
6402
+ (this.cdcRegistrationDirty ||
6403
+ !hasCdcRegistrationSync(this.cdcRegistrationKey()))))) {
6404
+ await this.ensureCdcRegistration();
6405
+ }
6406
+ const response = await this.httpClient.post(this.url('/batch'), JSON.stringify({ statements: sqls }), { 'Content-Type': 'application/json' });
6407
+ let capturedChanges;
6408
+ try {
6409
+ const parsed = JSON.parse(response);
6410
+ if (Object.hasOwn(parsed, 'capturedChanges')) {
6411
+ capturedChanges = Number(parsed.capturedChanges ?? 0);
6412
+ }
6413
+ }
6414
+ catch { }
6415
+ if ((capturedChanges ?? 0) > 0 ||
6416
+ (hasPublishedTrackedWrite && capturedChanges === undefined)) {
6177
6417
  this.signalTrackedWrite();
6418
+ }
6178
6419
  sqls = [];
6179
6420
  };
6180
6421
  for (const statement of statements) {
@@ -6183,8 +6424,10 @@ export class DoBackend {
6183
6424
  : statement;
6184
6425
  if (!item.sql.trim())
6185
6426
  continue;
6186
- if (item.isDDL)
6427
+ if (item.isDDL) {
6187
6428
  this.publicationTableInfoCache = null;
6429
+ this.invalidateCdcRegistration();
6430
+ }
6188
6431
  if (item.skipIfColumnExists || item.skipIfColumnMissing || item.skipIfTableEmpty) {
6189
6432
  await flush();
6190
6433
  if (item.skipIfColumnExists &&
@@ -6202,15 +6445,22 @@ export class DoBackend {
6202
6445
  }
6203
6446
  const tracking = this.trackingForStatement(item);
6204
6447
  const exec = await this.materializePublishedSchemaFunctions(tracking?.returningSQL ?? item.sql, item);
6205
- sqls.push(tracking
6448
+ const transactionID = this.inTransaction ? this.currentTransactionID() : undefined;
6449
+ const request = tracking
6206
6450
  ? {
6207
6451
  sql: exec.sql,
6208
6452
  ...(exec.params.length ? { params: exec.params } : null),
6209
6453
  track: this.trackingRequest(tracking),
6454
+ ...(transactionID ? { transactionID } : null),
6210
6455
  }
6211
- : exec.params.length
6212
- ? { sql: exec.sql, params: exec.params }
6213
- : exec.sql);
6456
+ : exec.params.length || transactionID
6457
+ ? {
6458
+ sql: exec.sql,
6459
+ ...(exec.params.length ? { params: exec.params } : null),
6460
+ ...(transactionID ? { transactionID } : null),
6461
+ }
6462
+ : exec.sql;
6463
+ sqls.push(request);
6214
6464
  }
6215
6465
  await flush();
6216
6466
  }
@@ -6224,12 +6474,16 @@ export class DoBackend {
6224
6474
  }
6225
6475
  async listSqliteTables() {
6226
6476
  const result = await this.doExecResult("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name");
6227
- return result.rows
6477
+ return (result.rows
6228
6478
  .map((row) => ({
6229
6479
  name: String(row.name ?? ''),
6230
6480
  sql: row.sql === null || row.sql === undefined ? null : String(row.sql),
6231
6481
  }))
6232
- .filter((table) => table.name);
6482
+ // Cloudflare owns _cf_* tables and rejects even PRAGMA introspection.
6483
+ // They are storage implementation details, never PostgreSQL relations.
6484
+ .filter((table) => table.name &&
6485
+ !table.name.toLowerCase().startsWith('_cf_') &&
6486
+ !table.name.toLowerCase().startsWith('sqlite_')));
6233
6487
  }
6234
6488
  tableRefForSqliteTable(name) {
6235
6489
  const metadata = this.schemaMetadata.get(name)?.values().next().value;