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,7 +1,9 @@
1
1
  // @ts-nocheck — cloudflare:workers types not available in orez
2
2
  import { DurableObject } from 'cloudflare:workers';
3
- import { isSqlMutation, RollingRowWriteBudget, trackSqlCursorRowsWritten, trackedChangeRow, WriteBudgetExceededError, } from '../do-sql-tracking.js';
4
- import { commitTxJournal, recoverTxJournal, rollbackTxJournal } from './tx-journal.js';
3
+ import { isSqlMutation, isSqlRowMutation, RollingRowWriteBudget, trackSqlCursorRowsWritten, trackedChangeRow, WriteBudgetExceededError, } from '../do-sql-tracking.js';
4
+ import { TransactionalCdc, } from './cdc.js';
5
+ import { appendPendingChange, deletePendingChanges, ensurePendingChangesTable, rollbackPendingChanges, } from './row-undo.js';
6
+ import { commitTxJournal, recoverTxJournal, rollbackTxJournal, snapshotSideEffectWriteTables, upgradeToTableSnapshot, } from './tx-journal.js';
5
7
  import { DurableWatermarkState } from './watermark.js';
6
8
  const SCHEMA_VERSION = 1;
7
9
  const SQL_ERROR_SNIPPET_RADIUS = 1600;
@@ -61,12 +63,14 @@ function sqlErrorSnippet(sql, message) {
61
63
  export class ZeroDO extends DurableObject {
62
64
  sql;
63
65
  watermarks;
66
+ cdc;
64
67
  schemaTables = new Set();
65
68
  tableSchemas = new Map();
66
69
  writeBudget;
67
70
  writeBudgetDisabled;
68
71
  writeBudgetAdminToken;
69
72
  activeWriteMeasurements = null;
73
+ pendingChangesSchemaReady = false;
70
74
  recordWriteBudgetRows(rows) {
71
75
  const wasTripped = this.writeBudget.status().tripped;
72
76
  try {
@@ -144,6 +148,7 @@ export class ZeroDO extends DurableObject {
144
148
  this.writeBudget.restoreTrip(trippedAt);
145
149
  });
146
150
  }
151
+ this.cdc = new TransactionalCdc(this.sql);
147
152
  this.watermarks = new DurableWatermarkState(this.sql);
148
153
  }
149
154
  async fetch(request) {
@@ -421,15 +426,15 @@ export class ZeroDO extends DurableObject {
421
426
  const body = (await request.json());
422
427
  sql = body.sql;
423
428
  const params = Array.isArray(body.params) ? body.params : [];
424
- // Only wrap in ctx.storage.transaction() when the call has change-tracking
425
- // side effects (executeSQL writes BOTH the user table AND _zero_changes,
426
- // which must commit together to keep source-tab sync flicker-free). A
427
- // bare /exec is single-statement and ctx.storage.sql already serializes;
428
- // the transaction wrap was adding ~2-5ms × every call, which on chat's
429
- // 27k-stmt boot pushed orez backend startup past chat's 60s wait-for-port.
430
- const result = await this.withSchemaProvisioningWait(() => body.track
431
- ? this.ctx.storage.transaction(() => this.executeSQL(sql, params, body.track))
432
- : this.executeSQL(sql, params));
429
+ const transactionID = String(body.transactionID || body.track?.transactionID || '');
430
+ // CDC rows are drained into the durable change log before the storage
431
+ // transaction returns. Keep unrelated DDL/read calls on the fast path:
432
+ // wrapping every /exec adds ~2-5ms and materially slows large schemas.
433
+ const needsAtomicCapture = !!body.track ||
434
+ (this.cdc.active && (isSqlRowMutation(sql) || this.cdc.capturesSchemaChange(sql)));
435
+ const result = await this.withSchemaProvisioningWait(() => needsAtomicCapture
436
+ ? this.atomically(() => this.executeSQL(sql, params, body.track, transactionID || undefined))
437
+ : this.executeSQL(sql, params, undefined, transactionID || undefined));
433
438
  return Response.json(measurements ? { ...result, writeMeasurements: measurements } : result);
434
439
  }
435
440
  catch (err) {
@@ -449,9 +454,11 @@ export class ZeroDO extends DurableObject {
449
454
  async handleBatch(request) {
450
455
  const measurements = this.startWriteMeasurement(request);
451
456
  try {
452
- const { statements } = (await request.json());
453
- const allRows = await this.withSchemaProvisioningWait(() => this.ctx.storage.transaction(() => {
457
+ const { statements, cdcTables } = (await request.json());
458
+ const allRows = await this.withSchemaProvisioningWait(() => this.atomically(() => {
454
459
  const results = [];
460
+ if (Array.isArray(cdcTables))
461
+ this.cdc.syncTables(cdcTables);
455
462
  for (const statement of statements) {
456
463
  const item = typeof statement === 'string' ? { sql: statement } : statement;
457
464
  if (!item?.sql?.trim())
@@ -465,7 +472,7 @@ export class ZeroDO extends DurableObject {
465
472
  continue;
466
473
  }
467
474
  try {
468
- results.push(this.executeSQL(item.sql, Array.isArray(item.params) ? item.params : [], item.track));
475
+ results.push(this.executeSQL(item.sql, Array.isArray(item.params) ? item.params : [], item.track, item.transactionID || item.track?.transactionID));
469
476
  }
470
477
  catch (err) {
471
478
  if (err instanceof WriteBudgetExceededError)
@@ -477,6 +484,7 @@ export class ZeroDO extends DurableObject {
477
484
  }));
478
485
  return Response.json({
479
486
  results: allRows,
487
+ capturedChanges: allRows.reduce((total, result) => total + Number(result.capturedChanges ?? 0), 0),
480
488
  ...(measurements ? { writeMeasurements: measurements } : null),
481
489
  });
482
490
  }
@@ -534,7 +542,7 @@ export class ZeroDO extends DurableObject {
534
542
  const transactionID = String(body.transactionID || '');
535
543
  if (!transactionID)
536
544
  throw new Error('missing transactionID');
537
- const count = await this.ctx.storage.transaction(() => {
545
+ const count = await this.atomically(() => {
538
546
  const committed = this.commitPendingTrackedChanges(transactionID);
539
547
  commitTxJournal(this.sql, transactionID);
540
548
  return committed;
@@ -563,7 +571,8 @@ export class ZeroDO extends DurableObject {
563
571
  const transactionID = String(body.transactionID || '');
564
572
  if (!transactionID)
565
573
  throw new Error('missing transactionID');
566
- const count = await this.ctx.storage.transaction(() => {
574
+ const count = await this.atomically(() => {
575
+ this.rollbackPendingTrackedChanges(transactionID);
567
576
  rollbackTxJournal(this.sql, transactionID);
568
577
  return this.deletePendingTrackedChanges(transactionID);
569
578
  });
@@ -593,8 +602,10 @@ export class ZeroDO extends DurableObject {
593
602
  try {
594
603
  const body = (await request.json().catch(() => ({})));
595
604
  const owner = body.owner === undefined ? undefined : String(body.owner);
596
- const transactionIDs = await this.ctx.storage.transaction(() => {
597
- const recovered = recoverTxJournal(this.sql, owner);
605
+ const transactionIDs = await this.atomically(() => {
606
+ const recovered = recoverTxJournal(this.sql, owner, (txID) => {
607
+ this.rollbackPendingTrackedChanges(txID);
608
+ });
598
609
  for (const txID of recovered)
599
610
  this.deletePendingTrackedChanges(txID);
600
611
  return recovered;
@@ -643,7 +654,7 @@ export class ZeroDO extends DurableObject {
643
654
  }
644
655
  async handleSnapshot() {
645
656
  try {
646
- return this.ctx.storage.transactionSync(() => {
657
+ return this.atomicallySync(() => {
647
658
  this.ensureSchemaMetadataTable();
648
659
  const names = this.sql
649
660
  .exec('SELECT name FROM _zero_schema_tables ORDER BY name')
@@ -662,6 +673,42 @@ export class ZeroDO extends DurableObject {
662
673
  return Response.json({ error: err.message }, { status: 500 });
663
674
  }
664
675
  }
676
+ /**
677
+ * Run work in a storage transaction, re-deriving every in-memory schema cache
678
+ * from SQLite if it aborts.
679
+ *
680
+ * ctx.storage.transaction() rolls the SQLite side back on throw, but the
681
+ * caches are plain fields that keep asserting state SQLite no longer has: a
682
+ * CDC table stays "registered and verified" with no trigger left on disk, so
683
+ * ensureTable short-circuits and every later write to it goes silently
684
+ * uncaptured. The readiness flags are the same class: their CREATE TABLE is
685
+ * rolled back while the flag still says the table exists.
686
+ */
687
+ async atomically(work) {
688
+ try {
689
+ return await this.ctx.storage.transaction(work);
690
+ }
691
+ catch (error) {
692
+ this.invalidateSchemaCaches();
693
+ throw error;
694
+ }
695
+ }
696
+ atomicallySync(work) {
697
+ try {
698
+ return this.ctx.storage.transactionSync(work);
699
+ }
700
+ catch (error) {
701
+ this.invalidateSchemaCaches();
702
+ throw error;
703
+ }
704
+ }
705
+ invalidateSchemaCaches() {
706
+ this.watermarks.invalidateCache();
707
+ this.pendingChangesSchemaReady = false;
708
+ // Reload is intentionally last because corrupt persisted CDC metadata must
709
+ // throw (fail closed), without preventing the other caches from invalidating.
710
+ this.cdc.reload();
711
+ }
665
712
  // sync (storage.transaction-safe) column presence check for the /batch
666
713
  // skipIfColumnExists/skipIfColumnMissing conditions. a missing table reads
667
714
  // as "no columns", which makes ADD COLUMN skips behave like pg's
@@ -675,26 +722,97 @@ export class ZeroDO extends DurableObject {
675
722
  return false;
676
723
  }
677
724
  }
678
- executeSQL(sql, params = [], track) {
679
- const cursor = this.sql.exec(sql, ...params);
725
+ executeSQL(sql, params = [], track, transactionID) {
726
+ let capturesTrackedTable = false;
727
+ if (track?.physicalTableName) {
728
+ capturesTrackedTable = this.cdc.ensureTable({
729
+ physicalTableName: track.physicalTableName,
730
+ tableName: track.tableName,
731
+ ...(track.publish === false ? { publish: false } : null),
732
+ ...(track.rowColumns?.length ? { columns: track.rowColumns } : null),
733
+ });
734
+ }
735
+ else if (track) {
736
+ capturesTrackedTable = this.cdc.capturesTable(track.tableName);
737
+ }
738
+ // DoBackend already marked this table row-journaled, betting the DO could
739
+ // capture before/after images for it. When it cannot, that marker promises
740
+ // a rollback nothing can perform, so take the table snapshot the journal
741
+ // would otherwise have taken. It has to happen before the DML, while the
742
+ // table still holds its pre-transaction contents.
743
+ const trackedTransactionID = track ? transactionID || track.transactionID : undefined;
744
+ let snapshotsOwnStatement = false;
745
+ if (track && !capturesTrackedTable && trackedTransactionID) {
746
+ const physicalTableName = track.physicalTableName || track.tableName.replace(/^public\./, '');
747
+ if (this.tableExists(physicalTableName)) {
748
+ upgradeToTableSnapshot(this.sql, trackedTransactionID, physicalTableName);
749
+ }
750
+ }
751
+ if (track && trackedTransactionID) {
752
+ const physicalTableName = track.physicalTableName || track.tableName.replace(/^public\./, '');
753
+ snapshotsOwnStatement = snapshotSideEffectWriteTables(this.sql, trackedTransactionID, physicalTableName);
754
+ }
755
+ const suspendedCdc = this.cdc.beginSchemaChange(sql);
756
+ let cursor;
757
+ try {
758
+ cursor = this.sql.exec(sql, ...params);
759
+ }
760
+ catch (error) {
761
+ // Restore capture against the unchanged schema before propagating a DDL
762
+ // failure. In normal /exec and /batch paths this is also protected by the
763
+ // surrounding storage transaction.
764
+ this.cdc.finishSchemaChange(suspendedCdc);
765
+ throw error;
766
+ }
767
+ this.cdc.finishSchemaChange(suspendedCdc);
680
768
  const columns = Array.isArray(cursor.columnNames) ? cursor.columnNames : [];
681
769
  const rows = this.cursorRows(cursor, columns);
682
- if (isSqlMutation(sql))
770
+ const mutation = isSqlMutation(sql);
771
+ if (mutation)
683
772
  this.writeBudget.recordLogical(rows.length);
684
- if (!track)
685
- return { rows, columns };
686
- for (const row of rows) {
687
- const trackedRow = trackedChangeRow(row, track);
688
- if (track.operation === 'DELETE')
689
- this.appendTrackedChange(track.tableName, 'DELETE', null, trackedRow, track.transactionID);
690
- else
691
- this.appendTrackedChange(track.tableName, track.operation, trackedRow, null, track.transactionID);
773
+ if (mutation && !isSqlRowMutation(sql))
774
+ this.cdc.invalidateSchema();
775
+ const captured = track || (this.cdc.active && isSqlRowMutation(sql)) ? this.cdc.drain() : [];
776
+ for (const change of captured) {
777
+ this.appendCapturedChange(change, transactionID || track?.transactionID, !snapshotsOwnStatement);
778
+ }
779
+ // Backward compatibility for callers that have not yet supplied the
780
+ // physical SQLite table identity needed to install a trigger. Once a
781
+ // table is registered, trigger capture is the sole source of truth and
782
+ // includes arbitrary business-trigger side effects.
783
+ //
784
+ // These rows carry no before-image, so they only feed the changefeed. Their
785
+ // rollback is owned by the table snapshot taken above, and `undoable: false`
786
+ // keeps the row-undo pass from trying to restore them from a wire image that
787
+ // cannot round-trip a blob or an int64.
788
+ if (track && !capturesTrackedTable) {
789
+ const physicalTableName = track.physicalTableName || track.tableName.replace(/^public\./, '');
790
+ for (const row of rows) {
791
+ const trackedRow = trackedChangeRow(row, track);
792
+ const isDelete = track.operation === 'DELETE';
793
+ this.appendTrackedChange({
794
+ tableName: track.tableName,
795
+ op: isDelete ? 'DELETE' : track.operation,
796
+ rowData: isDelete ? null : trackedRow,
797
+ oldData: isDelete ? trackedRow : null,
798
+ transactionID: transactionID || track.transactionID,
799
+ physicalTableName,
800
+ publish: track.publish !== false,
801
+ undoable: false,
802
+ });
803
+ }
804
+ if (track.publish !== false)
805
+ this.appendDerivedTrackedChanges(track, rows);
692
806
  }
693
- this.appendDerivedTrackedChanges(track, rows);
807
+ const publishedCaptured = captured.filter((change) => change.publish !== false).length;
808
+ if (!track)
809
+ return { rows, columns, capturedChanges: publishedCaptured };
694
810
  return {
695
811
  rows: track.returnRows ? rows : [],
696
812
  columns: track.returnRows ? columns : [],
697
813
  affectedRows: rows.length,
814
+ capturedChanges: publishedCaptured ||
815
+ (capturesTrackedTable || track.publish === false ? 0 : rows.length),
698
816
  };
699
817
  }
700
818
  cursorRows(cursor, columns) {
@@ -745,8 +863,19 @@ export class ZeroDO extends DurableObject {
745
863
  const rows = this.sql
746
864
  .exec(`SELECT * FROM ${quoteIdent(sqliteTableName)} WHERE ${quoteIdent(keyColumn)} IN (${placeholders})`, ...values)
747
865
  .toArray();
866
+ // Derived notifications: the rows were written by business triggers and are
867
+ // captured for the changefeed only. They carry no physical table name, so
868
+ // the row-undo pass never sees them, and their real writes are rolled back
869
+ // by whichever journal entry owns the table.
748
870
  for (const row of rows) {
749
- this.appendTrackedChange(publicTableName, 'UPDATE', row, null, transactionID);
871
+ this.appendTrackedChange({
872
+ tableName: publicTableName,
873
+ op: 'UPDATE',
874
+ rowData: row,
875
+ oldData: null,
876
+ transactionID,
877
+ undoable: false,
878
+ });
750
879
  }
751
880
  }
752
881
  messageRowUpdatesChannelLatestOrder(row) {
@@ -872,14 +1001,48 @@ export class ZeroDO extends DurableObject {
872
1001
  this.appendChange(tn, 'DELETE', null, existing);
873
1002
  }
874
1003
  appendChange(tn, op, rowData, oldData) {
875
- this.appendTrackedChange(tn, op, rowData, oldData);
1004
+ this.appendTrackedChange({ tableName: tn, op, rowData, oldData });
1005
+ }
1006
+ /** Record a trigger-captured change, before-images and row identity included. */
1007
+ appendCapturedChange(change, transactionID, undoable = true) {
1008
+ this.appendTrackedChange({
1009
+ tableName: change.tableName,
1010
+ op: change.op,
1011
+ rowData: change.rowData,
1012
+ oldData: change.oldData,
1013
+ transactionID,
1014
+ physicalTableName: change.physicalTableName,
1015
+ publish: change.publish !== false,
1016
+ rowJournal: change.rowJournal,
1017
+ oldJournal: change.oldJournal,
1018
+ newRowid: change.newRowid,
1019
+ oldRowid: change.oldRowid,
1020
+ undoable,
1021
+ });
876
1022
  }
877
- appendTrackedChange(tableName, op, rowData, oldData, transactionID) {
878
- if (transactionID) {
879
- this.appendPendingTrackedChange(tableName, op, rowData, oldData, transactionID);
1023
+ appendTrackedChange(change) {
1024
+ const publish = change.publish !== false;
1025
+ if (!publish && !change.transactionID)
1026
+ return;
1027
+ if (change.transactionID) {
1028
+ this.ensurePendingTrackedChangesTable();
1029
+ appendPendingChange(this.sql, {
1030
+ transactionID: change.transactionID,
1031
+ physicalTableName: change.physicalTableName,
1032
+ tableName: change.tableName,
1033
+ publish,
1034
+ op: change.op,
1035
+ rowData: change.rowData,
1036
+ oldData: change.oldData,
1037
+ rowJournal: change.rowJournal ?? null,
1038
+ oldJournal: change.oldJournal ?? null,
1039
+ newRowid: change.newRowid ?? null,
1040
+ oldRowid: change.oldRowid ?? null,
1041
+ undoable: change.undoable === true,
1042
+ });
880
1043
  return;
881
1044
  }
882
- this.appendCommittedTrackedChange(tableName, op, rowData, oldData);
1045
+ this.appendCommittedTrackedChange(change.tableName, change.op, change.rowData, change.oldData);
883
1046
  }
884
1047
  appendCommittedTrackedChange(tableName, op, rowData, oldData) {
885
1048
  this.watermarks.ensureTables();
@@ -888,11 +1051,14 @@ export class ZeroDO extends DurableObject {
888
1051
  this.watermarks.mark(watermark);
889
1052
  }
890
1053
  ensurePendingTrackedChangesTable() {
891
- this.sql.exec("CREATE TABLE IF NOT EXISTS _zero_pending_changes (id INTEGER PRIMARY KEY AUTOINCREMENT, transaction_id TEXT NOT NULL, table_name TEXT NOT NULL, op TEXT NOT NULL CHECK (op IN ('INSERT', 'UPDATE', 'DELETE')), row_data TEXT, old_data TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()))");
1054
+ if (this.pendingChangesSchemaReady)
1055
+ return;
1056
+ ensurePendingChangesTable(this.sql);
1057
+ this.pendingChangesSchemaReady = true;
892
1058
  }
893
- appendPendingTrackedChange(tableName, op, rowData, oldData, transactionID) {
1059
+ rollbackPendingTrackedChanges(transactionID) {
894
1060
  this.ensurePendingTrackedChangesTable();
895
- this.sql.exec('INSERT INTO _zero_pending_changes (transaction_id, table_name, op, row_data, old_data) VALUES (?, ?, ?, ?, ?)', transactionID, tableName, op, rowData ? JSON.stringify(rowData) : null, oldData ? JSON.stringify(oldData) : null);
1061
+ return rollbackPendingChanges(this.sql, transactionID);
896
1062
  }
897
1063
  commitPendingTrackedChanges(transactionID) {
898
1064
  this.ensurePendingTrackedChangesTable();
@@ -901,7 +1067,7 @@ export class ZeroDO extends DurableObject {
901
1067
  .exec(`INSERT INTO _zero_changes (table_name, op, row_data, old_data)
902
1068
  SELECT table_name, op, row_data, old_data
903
1069
  FROM _zero_pending_changes
904
- WHERE transaction_id = ?
1070
+ WHERE transaction_id = ? AND publish != 0
905
1071
  ORDER BY id
906
1072
  RETURNING watermark`, transactionID)
907
1073
  .toArray();
@@ -916,10 +1082,7 @@ export class ZeroDO extends DurableObject {
916
1082
  }
917
1083
  deletePendingTrackedChanges(transactionID) {
918
1084
  this.ensurePendingTrackedChangesTable();
919
- const result = this.sql
920
- .exec('DELETE FROM _zero_pending_changes WHERE transaction_id = ? RETURNING 1 AS deleted', transactionID)
921
- .toArray();
922
- return result.length;
1085
+ return deletePendingChanges(this.sql, transactionID);
923
1086
  }
924
1087
  readChangesSince(watermark) {
925
1088
  this.watermarks.ensureTables();
@@ -1078,7 +1241,16 @@ export class ZeroDO extends DurableObject {
1078
1241
  value === true || value === 1 || value === '1' || value === 'true';
1079
1242
  }
1080
1243
  else if (type === 'number') {
1081
- normalized[key] = Number(value);
1244
+ // timestamp/timestamptz columns are declared `number` in the zero
1245
+ // schema but stored as postgres timestamp TEXT (pg-proxy-do-backend
1246
+ // `postgresTimestampText`, e.g. "2026-07-11 13:34:46.000+00"). Coercing
1247
+ // that text with Number() yields NaN, which JSON serializes as null and
1248
+ // silently wipes every timestamp reaching the sync-cf-host snapshot
1249
+ // feed. Forward a non-numeric value untouched so the engine's
1250
+ // timestamp_text_to_epoch_ms decodes it, matching the /changes feed
1251
+ // which forwards the raw text.
1252
+ const numeric = Number(value);
1253
+ normalized[key] = Number.isFinite(numeric) ? numeric : value;
1082
1254
  }
1083
1255
  else if (type === 'json' && typeof value === 'string') {
1084
1256
  try {