Skip to content

Commit 9fa1675

Browse files
committed
fix: use dialect-aware timestamp defaults for SQLite compatibility
SQLite doesn't have a NOW() function, so entity column defaults failed when TypeORM inserted records without explicit timestamps (e.g. via onboardAgent). Add timestampDefault() that returns CURRENT_TIMESTAMP for SQLite and NOW() for PostgreSQL, matching the existing timestampType() pattern. Also parameterize is_active boolean in notification-rules raw SQL for SQLite.
1 parent 6786849 commit 9fa1675

File tree

10 files changed

+65
-24
lines changed

10 files changed

+65
-24
lines changed

packages/backend/src/common/utils/sql-dialect.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
detectDialect,
33
timestampType,
4+
timestampDefault,
45
computeCutoff,
56
sqlNow,
67
sqlHourBucket,
@@ -49,6 +50,32 @@ describe('sql-dialect', () => {
4950
});
5051
});
5152

53+
describe('timestampDefault', () => {
54+
const origMode = process.env['MANIFEST_MODE'];
55+
afterEach(() => {
56+
if (origMode === undefined) delete process.env['MANIFEST_MODE'];
57+
else process.env['MANIFEST_MODE'] = origMode;
58+
});
59+
60+
it('returns CURRENT_TIMESTAMP function for local mode', () => {
61+
process.env['MANIFEST_MODE'] = 'local';
62+
const fn = timestampDefault();
63+
expect(fn()).toBe('CURRENT_TIMESTAMP');
64+
});
65+
66+
it('returns NOW() function for cloud mode', () => {
67+
process.env['MANIFEST_MODE'] = 'cloud';
68+
const fn = timestampDefault();
69+
expect(fn()).toBe('NOW()');
70+
});
71+
72+
it('returns NOW() function when MANIFEST_MODE is unset', () => {
73+
delete process.env['MANIFEST_MODE'];
74+
const fn = timestampDefault();
75+
expect(fn()).toBe('NOW()');
76+
});
77+
});
78+
5279
describe('computeCutoff', () => {
5380
it('returns an ISO string in the past for "24 hours"', () => {
5481
const before = Date.now();

packages/backend/src/common/utils/sql-dialect.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ export function timestampType(): 'datetime' | 'timestamp' {
1313
return process.env['MANIFEST_MODE'] === 'local' ? 'datetime' : 'timestamp';
1414
}
1515

16+
/**
17+
* Returns the correct column default for timestamp columns.
18+
* Postgres uses NOW(), SQLite uses CURRENT_TIMESTAMP.
19+
* Evaluated at decorator time using MANIFEST_MODE env var.
20+
*/
21+
export function timestampDefault(): () => string {
22+
return process.env['MANIFEST_MODE'] === 'local'
23+
? () => 'CURRENT_TIMESTAMP'
24+
: () => 'NOW()';
25+
}
26+
1627
/**
1728
* Convert a Postgres-style interval string (e.g. '7 days', '24 hours')
1829
* to a JS Date cutoff. Both Postgres and SQLite can compare ISO timestamps.

packages/backend/src/entities/agent-api-key.entity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from 'typeorm';
1010
import { Tenant } from './tenant.entity';
1111
import { Agent } from './agent.entity';
12-
import { timestampType } from '../common/utils/sql-dialect';
12+
import { timestampType, timestampDefault } from '../common/utils/sql-dialect';
1313

1414
@Entity('agent_api_keys')
1515
export class AgentApiKey {
@@ -52,6 +52,6 @@ export class AgentApiKey {
5252
@Column(timestampType(), { nullable: true })
5353
last_used_at!: string | null;
5454

55-
@Column(timestampType(), { default: () => 'NOW()' })
55+
@Column(timestampType(), { default: timestampDefault() })
5656
created_at!: string;
5757
}

packages/backend/src/entities/agent.entity.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from 'typeorm';
1010
import { Tenant } from './tenant.entity';
1111
import { AgentApiKey } from './agent-api-key.entity';
12-
import { timestampType } from '../common/utils/sql-dialect';
12+
import { timestampType, timestampDefault } from '../common/utils/sql-dialect';
1313

1414
@Entity('agents')
1515
@Index(['tenant_id', 'name'], { unique: true })
@@ -36,9 +36,9 @@ export class Agent {
3636
@OneToOne(() => AgentApiKey, (k) => k.agent, { cascade: true })
3737
apiKey!: AgentApiKey;
3838

39-
@Column(timestampType(), { default: () => 'NOW()' })
39+
@Column(timestampType(), { default: timestampDefault() })
4040
created_at!: string;
4141

42-
@Column(timestampType(), { default: () => 'NOW()' })
42+
@Column(timestampType(), { default: timestampDefault() })
4343
updated_at!: string;
4444
}

packages/backend/src/entities/api-key.entity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Entity, Column, PrimaryColumn, Index } from 'typeorm';
2-
import { timestampType } from '../common/utils/sql-dialect';
2+
import { timestampType, timestampDefault } from '../common/utils/sql-dialect';
33

44
@Entity('api_keys')
55
export class ApiKey {
@@ -22,7 +22,7 @@ export class ApiKey {
2222
@Column('varchar')
2323
name!: string;
2424

25-
@Column(timestampType(), { default: () => 'NOW()' })
25+
@Column(timestampType(), { default: timestampDefault() })
2626
created_at!: string;
2727

2828
@Column(timestampType(), { nullable: true, default: null })

packages/backend/src/entities/notification-log.entity.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
PrimaryColumn,
55
Index,
66
} from 'typeorm';
7-
import { timestampType } from '../common/utils/sql-dialect';
7+
import { timestampType, timestampDefault } from '../common/utils/sql-dialect';
88

99
@Entity('notification_logs')
1010
@Index(['rule_id', 'period_start'], { unique: true })
@@ -33,6 +33,6 @@ export class NotificationLog {
3333
@Column('varchar')
3434
agent_name!: string;
3535

36-
@Column(timestampType(), { default: () => 'NOW()' })
36+
@Column(timestampType(), { default: timestampDefault() })
3737
sent_at!: string;
3838
}

packages/backend/src/entities/notification-rule.entity.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
PrimaryColumn,
55
Index,
66
} from 'typeorm';
7-
import { timestampType } from '../common/utils/sql-dialect';
7+
import { timestampType, timestampDefault } from '../common/utils/sql-dialect';
88

99
@Entity('notification_rules')
1010
@Index(['tenant_id', 'agent_id'])
@@ -36,9 +36,9 @@ export class NotificationRule {
3636
@Column('boolean', { default: true })
3737
is_active!: boolean;
3838

39-
@Column(timestampType(), { default: () => 'NOW()' })
39+
@Column(timestampType(), { default: timestampDefault() })
4040
created_at!: string;
4141

42-
@Column(timestampType(), { default: () => 'NOW()' })
42+
@Column(timestampType(), { default: timestampDefault() })
4343
updated_at!: string;
4444
}

packages/backend/src/entities/tenant.entity.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Entity, Column, PrimaryColumn, OneToMany } from 'typeorm';
22
import { Agent } from './agent.entity';
3-
import { timestampType } from '../common/utils/sql-dialect';
3+
import { timestampType, timestampDefault } from '../common/utils/sql-dialect';
44

55
@Entity('tenants')
66
export class Tenant {
@@ -22,9 +22,9 @@ export class Tenant {
2222
@OneToMany(() => Agent, (a) => a.tenant, { cascade: true })
2323
agents!: Agent[];
2424

25-
@Column(timestampType(), { default: () => 'NOW()' })
25+
@Column(timestampType(), { default: timestampDefault() })
2626
created_at!: string;
2727

28-
@Column(timestampType(), { default: () => 'NOW()' })
28+
@Column(timestampType(), { default: timestampDefault() })
2929
updated_at!: string;
3030
}

packages/backend/src/notifications/services/notification-rules.service.spec.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,8 @@ describe('NotificationRulesService', () => {
233233
const result = await service.getAllActiveRules();
234234
expect(result).toEqual(rules);
235235
expect(mockQuery).toHaveBeenCalledWith(
236-
expect.stringContaining('is_active = true'),
236+
expect.stringContaining('is_active = $1'),
237+
[true],
237238
);
238239
});
239240
});
@@ -251,7 +252,7 @@ describe('NotificationRulesService', () => {
251252
});
252253

253254
describe('createRule with PG params', () => {
254-
it('uses $1 through $10 numbered params in INSERT', async () => {
255+
it('uses $1 through $11 numbered params in INSERT', async () => {
255256
mockQuery
256257
.mockResolvedValueOnce([{ id: 'agent-1', tenant_id: 'tenant-1' }]) // resolveAgent
257258
.mockResolvedValueOnce(undefined) // INSERT
@@ -269,8 +270,8 @@ describe('NotificationRulesService', () => {
269270
const params = insertCall[1] as unknown[];
270271

271272
expect(sql).toContain('$1');
272-
expect(sql).toContain('$10');
273-
expect(params).toHaveLength(10);
273+
expect(sql).toContain('$11');
274+
expect(params).toHaveLength(11);
274275
});
275276
});
276277
});
@@ -321,8 +322,8 @@ describe('NotificationRulesService (SQLite dialect)', () => {
321322
const params = insertCall[1] as unknown[];
322323

323324
expect(sql).not.toContain('$1');
324-
expect((sql.match(/\?/g) ?? []).length).toBe(10);
325-
expect(params).toHaveLength(10);
325+
expect((sql.match(/\?/g) ?? []).length).toBe(11);
326+
expect(params).toHaveLength(11);
326327
});
327328

328329
it('converts is_active boolean to 1/0 for sqlite in updateRule', async () => {

packages/backend/src/notifications/services/notification-rules.service.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ export class NotificationRulesService {
4040
this.sql(
4141
`INSERT INTO notification_rules
4242
(id, tenant_id, agent_id, agent_name, user_id, metric_type, threshold, period, is_active, created_at, updated_at)
43-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10)`,
43+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
4444
),
4545
[id, agent.tenant_id, agent.id, dto.agent_name, userId,
46-
dto.metric_type, dto.threshold, dto.period, now, now],
46+
dto.metric_type, dto.threshold, dto.period,
47+
this.dialect === 'sqlite' ? 1 : true, now, now],
4748
);
4849

4950
const rows = await this.ds.query(this.sql(`SELECT * FROM notification_rules WHERE id = $1`), [id]);
@@ -108,7 +109,8 @@ export class NotificationRulesService {
108109

109110
async getAllActiveRules() {
110111
return this.ds.query(
111-
this.sql(`SELECT * FROM notification_rules WHERE is_active = true`),
112+
this.sql(`SELECT * FROM notification_rules WHERE is_active = $1`),
113+
[this.dialect === 'sqlite' ? 1 : true],
112114
);
113115
}
114116

0 commit comments

Comments
 (0)