Skip to content

Commit 6d3bf54

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(messaging): store outbox audit timestamps as datetime for Postgres (#2228)
created_at/updated_at are builtin audit columns the SQL driver always creates as native TIMESTAMP columns, regardless of the declared field type. The notification + HTTP outboxes declared them as Field.number and wrote epoch-ms via Date.now(), so on Postgres both enqueue inserts and the retention sweep threw "date/time field value out of range" (bigint vs timestamp). SQLite's lenient affinity hid it until the multi-node Postgres E2E. - objects: created_at/updated_at -> Field.datetime (matches the real column and registers them for the driver's per-dialect filter coercion) - outboxes: enqueue writes Date; toRecord normalises read-back via toEpochMs so the record contract stays epoch ms - retention: one ISO-8601 cutoff for every target (drop format:'epoch') - tests: retention asserts an ISO-string cutoff; new driver-sql prune regression against a builtin created_at timestamp column service-job job-run-retention was already ISO (no change). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8cf4f7c commit 6d3bf54

9 files changed

Lines changed: 187 additions & 36 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/service-messaging": patch
3+
---
4+
5+
fix(messaging): store outbox audit timestamps as datetime so Postgres retention works
6+
7+
`created_at`/`updated_at` are builtin audit columns that the SQL driver always
8+
provisions as native `TIMESTAMP` columns, regardless of the declared field type.
9+
The notification and HTTP outboxes declared them as `Field.number` and wrote
10+
epoch-ms via `Date.now()`, so on Postgres both the `enqueue` insert and the
11+
retention sweep failed with `date/time field value out of range` (a bigint
12+
compared to a timestamp column). SQLite's lenient column affinity hid the bug
13+
until the multi-node Postgres E2E.
14+
15+
The outbox objects now declare these as `Field.datetime` and write `Date`s; the
16+
retention sweep uses one ISO-8601 cutoff for every target (dropping the
17+
`format: 'epoch'` special case); `toRecord` normalises read-back to epoch ms so
18+
the record contract is unchanged. `sys_job_run` retention was already ISO.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Regression for the messaging / job retention sweeps on Postgres
5+
* (service-messaging `NotificationRetention`, service-job `JobRunRetention`):
6+
*
7+
* The builtin `created_at` audit column is provisioned as a native `TIMESTAMP`
8+
* (`table.timestamp`), so a retention prune MUST filter it with an ISO-8601
9+
* cutoff. An earlier sweep passed a bare epoch-ms number, which compares a
10+
* bigint to a timestamp column — Postgres rejects it ("date/time field value
11+
* out of range"). SQLite's lenient column affinity hid the bug.
12+
*
13+
* This proves the path the sweep now relies on: declaring the builtin
14+
* `created_at` column as `datetime` registers it for per-dialect filter
15+
* coercion, so a `created_at < <ISO>` delete prunes exactly the aged rows. Rows
16+
* are written as JS `Date`s, exactly as the outboxes now do.
17+
*/
18+
19+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
20+
import { SqlDriver } from '../src/index.js';
21+
22+
describe('SqlDriver retention prune on a builtin created_at timestamp column', () => {
23+
let driver: SqlDriver;
24+
25+
beforeEach(async () => {
26+
driver = new SqlDriver({
27+
client: 'better-sqlite3',
28+
connection: { filename: ':memory:' },
29+
useNullAsDefault: true,
30+
});
31+
32+
// `created_at` is a builtin audit column; declaring it `datetime` (as the
33+
// outbox objects now do) registers it for filter coercion without creating a
34+
// duplicate column.
35+
await driver.initObjects([
36+
{
37+
name: 'retention_probe',
38+
fields: { created_at: { type: 'datetime' }, label: { type: 'string' } },
39+
},
40+
]);
41+
42+
// Write `created_at` as `Date` objects — the outbox enqueue convention. On
43+
// SQLite better-sqlite3 stores these as INTEGER milliseconds.
44+
await driver.create('retention_probe',
45+
{ id: 'old1', label: 'old', created_at: new Date('2025-01-01T00:00:00Z') }, { bypassTenantAudit: true });
46+
await driver.create('retention_probe',
47+
{ id: 'old2', label: 'old', created_at: new Date('2025-02-01T00:00:00Z') }, { bypassTenantAudit: true });
48+
await driver.create('retention_probe',
49+
{ id: 'new1', label: 'new', created_at: new Date('2026-06-01T00:00:00Z') }, { bypassTenantAudit: true });
50+
});
51+
52+
afterEach(async () => {
53+
await driver.disconnect();
54+
});
55+
56+
it('prunes rows older than an ISO-8601 cutoff and keeps recent ones', async () => {
57+
const cutoffIso = new Date('2026-01-01T00:00:00.000Z').toISOString();
58+
59+
const deleted = await driver.deleteMany(
60+
'retention_probe',
61+
{ where: { created_at: { $lt: cutoffIso } } },
62+
{ bypassTenantAudit: true },
63+
);
64+
expect(deleted).toBe(2);
65+
66+
const remaining = await driver.find('retention_probe', {});
67+
expect(remaining.map((r: any) => r.id)).toEqual(['new1']);
68+
});
69+
70+
it('an ISO-8601 cutoff before every row deletes nothing', async () => {
71+
const deleted = await driver.deleteMany(
72+
'retention_probe',
73+
{ where: { created_at: { $lt: '2020-01-01T00:00:00.000Z' } } },
74+
{ bypassTenantAudit: true },
75+
);
76+
expect(deleted).toBe(0);
77+
78+
const remaining = await driver.find('retention_probe', {});
79+
expect(remaining.map((r: any) => r.id).sort()).toEqual(['new1', 'old1', 'old2']);
80+
});
81+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Normalise a builtin `created_at` / `updated_at` value to epoch milliseconds.
5+
*
6+
* Those two columns are provisioned as native `TIMESTAMP` columns by the SQL
7+
* driver (Postgres/MySQL), so the outboxes WRITE them as `Date`s — never a bare
8+
* epoch-ms number, which a real timestamp column rejects (the bug that broke the
9+
* `sys_notification_delivery` retention sweep on Postgres). The read-back form is
10+
* therefore dialect-dependent: epoch-ms on SQLite, a `Date` (or ISO string) on
11+
* Postgres. This collapses all of those back to epoch-ms so the outbox record
12+
* contract (`createdAt` / `updatedAt: number`) stays driver-independent.
13+
*/
14+
export function toEpochMs(value: unknown): number {
15+
if (typeof value === 'number') return value;
16+
if (value instanceof Date) return value.getTime();
17+
if (typeof value === 'string') {
18+
const parsed = Date.parse(value);
19+
if (Number.isFinite(parsed)) return parsed;
20+
const numeric = Number(value);
21+
if (Number.isFinite(numeric)) return numeric;
22+
}
23+
return 0;
24+
}

packages/services/service-messaging/src/objects/http-delivery.object.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,11 @@ export const HttpDelivery = ObjectSchema.create({
149149
response_body: Field.textarea({ label: 'Response Body (capped)', required: false }),
150150
error: Field.textarea({ label: 'Error', required: false }),
151151

152-
created_at: Field.number({ label: 'Created At (ms)', required: true }),
153-
updated_at: Field.number({ label: 'Updated At (ms)', required: true }),
152+
// Builtin audit columns are native TIMESTAMP columns (Postgres/MySQL),
153+
// so declare them `datetime` and write `Date`s (not epoch-ms numbers,
154+
// which a real timestamp column rejects). See SqlHttpOutbox.
155+
created_at: Field.datetime({ label: 'Created At', required: true }),
156+
updated_at: Field.datetime({ label: 'Updated At', required: true }),
154157
},
155158

156159
indexes: [

packages/services/service-messaging/src/objects/notification-delivery.object.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1313
*
1414
* `payload` snapshots the rendered notification content at enqueue time so a
1515
* later edit of the L2 event can't rewrite an in-flight delivery (and so the
16-
* dispatcher needs no second read to send). Timestamps are epoch ms.
16+
* dispatcher needs no second read to send). Scheduling fields (`claimed_at`,
17+
* `next_attempt_at`, `last_attempted_at`) are epoch ms; the builtin
18+
* `created_at` / `updated_at` audit columns are native timestamps.
1719
*/
1820
export const NotificationDelivery = ObjectSchema.create({
1921
name: 'sys_notification_delivery',
@@ -67,8 +69,12 @@ export const NotificationDelivery = ObjectSchema.create({
6769
last_attempted_at: Field.number({ label: 'Last Attempted At (ms)' }),
6870
error: Field.textarea({ label: 'Error' }),
6971

70-
created_at: Field.number({ label: 'Created At (ms)', readonly: true }),
71-
updated_at: Field.number({ label: 'Updated At (ms)' }),
72+
// Builtin audit columns: the SQL driver provisions `created_at` /
73+
// `updated_at` as native TIMESTAMP columns (Postgres/MySQL), so they are
74+
// declared `datetime` and written as `Date`s — a bare epoch-ms number is
75+
// rejected by a real timestamp column. See SqlNotificationOutbox.
76+
created_at: Field.datetime({ label: 'Created At', readonly: true }),
77+
updated_at: Field.datetime({ label: 'Updated At' }),
7278
},
7379

7480
indexes: [

packages/services/service-messaging/src/retention.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function captureEngine(deleteImpl?: (object: string, opts: any) => any) {
3030
const FIXED_NOW = 1_700_000_000_000; // fixed clock for deterministic cutoffs
3131

3232
describe('NotificationRetention', () => {
33-
it('prunes every target older than the cutoff, formatting the cutoff per target', async () => {
33+
it('prunes every target older than an ISO-8601 cutoff (never a bare epoch-ms number)', async () => {
3434
const { engine, deletes } = captureEngine();
3535
const retention = new NotificationRetention({
3636
getData: () => engine,
@@ -50,12 +50,17 @@ describe('NotificationRetention', () => {
5050
// Cross-tenant system context — retention is an operator policy.
5151
expect(d.context).toEqual({ isSystem: true });
5252
}
53-
// The delivery row stores epoch-ms; everything else stores ISO strings.
53+
// Every target's `created_at` is a native timestamp column, so the cutoff
54+
// is an ISO-8601 string — NEVER a bare epoch-ms number (a bigint compared
55+
// to a timestamp column makes Postgres throw "date/time field value out of
56+
// range"; SQLite's lenient affinity used to hide it for the delivery outbox).
5457
const byObject = Object.fromEntries(deletes.map((d) => [d.object, d.where]));
55-
expect(byObject['sys_notification_delivery']).toEqual({ created_at: { $lt: cutoffMs } });
56-
expect(byObject['sys_notification']).toEqual({ created_at: { $lt: cutoffIso } });
57-
expect(byObject['sys_inbox_message']).toEqual({ created_at: { $lt: cutoffIso } });
58-
expect(byObject['sys_notification_receipt']).toEqual({ created_at: { $lt: cutoffIso } });
58+
for (const obj of DEFAULT_RETENTION_TARGETS.map((t) => t.object)) {
59+
expect(byObject[obj]).toEqual({ created_at: { $lt: cutoffIso } });
60+
const lt = (byObject[obj].created_at as { $lt: unknown }).$lt;
61+
expect(typeof lt).toBe('string');
62+
expect(lt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
63+
}
5964

6065
expect(outcomes.every((o) => o.deleted === 1 && !o.error)).toBe(true);
6166
});
@@ -100,7 +105,7 @@ describe('NotificationRetention', () => {
100105
getData: () => engine,
101106
logger: silentLogger(),
102107
now: () => FIXED_NOW,
103-
targets: [{ object: 'sys_notification', tsField: 'created_at', format: 'iso' }],
108+
targets: [{ object: 'sys_notification', tsField: 'created_at' }],
104109
});
105110
const outcomes = await retention.prune(1);
106111
expect(outcomes).toEqual([{ object: 'sys_notification', deleted: undefined }]);

packages/services/service-messaging/src/retention.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,17 @@ interface RetentionLogger {
2121
}
2222

2323
/**
24-
* One object the sweeper prunes, plus how to read its age. The pipeline stores
25-
* timestamps two ways: the event / inbox / receipt rows carry ISO-8601 strings
26-
* (`created_at`), while the delivery outbox rows carry epoch-ms numbers — so the
27-
* cutoff value is formatted per target. `$lt` works for both (ISO-8601 sorts
28-
* lexicographically; epoch-ms numerically).
24+
* One object the sweeper prunes, plus the field that carries its age. Every
25+
* target's `created_at` is a builtin audit column — a native `TIMESTAMP` on
26+
* Postgres/MySQL — so the cutoff is always an ISO-8601 string. (An earlier
27+
* version passed an epoch-ms number for the delivery outbox; that compared a
28+
* bigint to a timestamp column and Postgres rejected it with "date/time field
29+
* value out of range". SQLite's lenient column affinity hid the bug.) The
30+
* driver coerces the ISO comparand to the column's storage form per dialect.
2931
*/
3032
export interface RetentionTarget {
3133
readonly object: string;
3234
readonly tsField: string;
33-
readonly format: 'iso' | 'epoch';
3435
}
3536

3637
/**
@@ -42,10 +43,10 @@ export interface RetentionTarget {
4243
* unaffected.
4344
*/
4445
export const DEFAULT_RETENTION_TARGETS: readonly RetentionTarget[] = [
45-
{ object: RECEIPT_OBJECT, tsField: 'created_at', format: 'iso' },
46-
{ object: INBOX_OBJECT, tsField: 'created_at', format: 'iso' },
47-
{ object: DELIVERY_OBJECT, tsField: 'created_at', format: 'epoch' },
48-
{ object: NOTIFICATION_EVENT_OBJECT, tsField: 'created_at', format: 'iso' },
46+
{ object: RECEIPT_OBJECT, tsField: 'created_at' },
47+
{ object: INBOX_OBJECT, tsField: 'created_at' },
48+
{ object: DELIVERY_OBJECT, tsField: 'created_at' },
49+
{ object: NOTIFICATION_EVENT_OBJECT, tsField: 'created_at' },
4950
];
5051

5152
export interface NotificationRetentionOptions {
@@ -103,15 +104,16 @@ export class NotificationRetention {
103104
return [];
104105
}
105106

106-
const cutoffMs = this.now() - retentionDays * 86_400_000;
107-
const cutoffIso = new Date(cutoffMs).toISOString();
107+
const cutoffIso = new Date(this.now() - retentionDays * 86_400_000).toISOString();
108108
const outcomes: PruneOutcome[] = [];
109109

110110
for (const t of this.targets) {
111-
const cutoff = t.format === 'epoch' ? cutoffMs : cutoffIso;
112111
try {
113112
const res = await data.delete(t.object, {
114-
where: { [t.tsField]: { $lt: cutoff } },
113+
// ISO-8601 cutoff for every target: `created_at` is a native
114+
// timestamp column, which rejects a bare epoch-ms number on
115+
// Postgres. The driver coerces this per dialect on the way down.
116+
where: { [t.tsField]: { $lt: cutoffIso } },
115117
multi: true,
116118
// System context: retention is an operator policy that spans
117119
// tenants, so it must not be scoped by the caller's RLS.

packages/services/service-messaging/src/sql-http-outbox.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { randomUUID } from 'node:crypto';
44
import type { IDataEngine } from '@objectstack/spec/contracts';
55
import { hashPartition } from './backoff.js';
6+
import { toEpochMs } from './audit-timestamp.js';
67
import {
78
HttpRedeliverError,
89
type EnqueueHttpInput,
@@ -46,8 +47,11 @@ interface DeliveryRow {
4647
response_code?: number | null;
4748
response_body?: string | null;
4849
error?: string | null;
49-
created_at: number;
50-
updated_at: number;
50+
// Builtin audit columns (native TIMESTAMP on Postgres/MySQL): WRITTEN as
51+
// `Date`s. Read-back form is dialect-dependent; `toRecord` normalises via
52+
// `toEpochMs`.
53+
created_at: number | string | Date;
54+
updated_at: number | string | Date;
5155
}
5256

5357
/**
@@ -83,7 +87,9 @@ export class SqlHttpOutbox implements IHttpOutbox {
8387
if (existing?.id) return existing.id as string;
8488

8589
const id = randomUUID();
86-
const now = Date.now();
90+
// `Date`, not epoch-ms: `created_at` / `updated_at` are native TIMESTAMP
91+
// columns and a real timestamp column rejects a bare number on Postgres.
92+
const now = new Date();
8793
const row: DeliveryRow = {
8894
id,
8995
source: input.source,
@@ -270,8 +276,8 @@ export class SqlHttpOutbox implements IHttpOutbox {
270276
responseCode: r.response_code ?? undefined,
271277
responseBody: r.response_body ?? undefined,
272278
error: r.error ?? undefined,
273-
createdAt: r.created_at,
274-
updatedAt: r.updated_at,
279+
createdAt: toEpochMs(r.created_at),
280+
updatedAt: toEpochMs(r.updated_at),
275281
};
276282
}
277283
}

packages/services/service-messaging/src/sql-outbox.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
NotificationDeliveryRecord,
1212
} from './outbox.js';
1313
import { hashPartition } from './backoff.js';
14+
import { toEpochMs } from './audit-timestamp.js';
1415

1516
export const DELIVERY_OBJECT = 'sys_notification_delivery';
1617

@@ -38,8 +39,11 @@ interface DeliveryRow {
3839
last_attempted_at?: number | null;
3940
error?: string | null;
4041
digest_key?: string | null;
41-
created_at: number;
42-
updated_at: number;
42+
// Builtin audit columns (native TIMESTAMP on Postgres/MySQL): WRITTEN as
43+
// `Date`s. Read-back form is dialect-dependent (epoch-ms on SQLite, a
44+
// `Date`/ISO string on Postgres); `toRecord` normalises via `toEpochMs`.
45+
created_at: number | string | Date;
46+
updated_at: number | string | Date;
4347
}
4448

4549
/**
@@ -70,7 +74,9 @@ export class SqlNotificationOutbox implements INotificationOutbox {
7074
if (existing?.id) return String(existing.id);
7175

7276
const id = randomUUID();
73-
const now = Date.now();
77+
// `Date`, not epoch-ms: `created_at` / `updated_at` are native TIMESTAMP
78+
// columns and a real timestamp column rejects a bare number on Postgres.
79+
const now = new Date();
7480
const row: DeliveryRow = {
7581
id,
7682
notification_id: input.notificationId,
@@ -254,8 +260,8 @@ export class SqlNotificationOutbox implements INotificationOutbox {
254260
lastAttemptedAt: r.last_attempted_at ?? undefined,
255261
error: r.error ?? undefined,
256262
digestKey: r.digest_key ?? undefined,
257-
createdAt: r.created_at,
258-
updatedAt: r.updated_at,
263+
createdAt: toEpochMs(r.created_at),
264+
updatedAt: toEpochMs(r.updated_at),
259265
};
260266
}
261267
}

0 commit comments

Comments
 (0)