Skip to content

Commit 5523540

Browse files
committed
Compatibility for times
1 parent c96b12e commit 5523540

File tree

10 files changed

+130
-47
lines changed

10 files changed

+130
-47
lines changed

libs/lib-services/src/codec/codecs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as t from 'ts-codec';
22
import * as bson from 'bson';
3-
import { TimeValue } from '@powersync/service-sync-rules';
3+
import { DateTimeValue } from '@powersync/service-sync-rules';
44

55
export const buffer = t.codec<Buffer, string>(
66
'Buffer',
@@ -13,7 +13,7 @@ export const buffer = t.codec<Buffer, string>(
1313
(buffer) => Buffer.from(buffer, 'base64')
1414
);
1515

16-
export const date = t.codec<Date, string | TimeValue>(
16+
export const date = t.codec<Date, string | DateTimeValue>(
1717
'Date',
1818
(date) => {
1919
if (!(date instanceof Date)) {
@@ -24,7 +24,7 @@ export const date = t.codec<Date, string | TimeValue>(
2424
(date) => {
2525
// In our jpgwire wrapper, we patch the row decoding logic to map timestamps into TimeValue instances, so we need to
2626
// support those here.
27-
const parsed = new Date(date instanceof TimeValue ? date.iso8601Representation : date);
27+
const parsed = new Date(date instanceof DateTimeValue ? date.iso8601Representation : date);
2828
if (isNaN(parsed.getTime())) {
2929
throw new t.TransformError([`Invalid date`]);
3030
}

modules/module-mongodb/src/replication/MongoRelation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
SqliteInputValue,
1212
SqliteRow,
1313
SqliteValue,
14-
TimeValue
14+
DateTimeValue
1515
} from '@powersync/service-sync-rules';
1616

1717
import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
@@ -72,7 +72,7 @@ export function toMongoSyncRulesValue(data: any): SqliteInputValue {
7272
return data.toHexString();
7373
} else if (data instanceof Date) {
7474
const isoString = data.toISOString();
75-
return new TimeValue(isoString);
75+
return new DateTimeValue(isoString);
7676
} else if (data instanceof mongo.Binary) {
7777
return new Uint8Array(data.buffer);
7878
} else if (data instanceof mongo.Long) {
@@ -125,7 +125,7 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
125125
return data;
126126
} else if (data instanceof Date) {
127127
const isoString = data.toISOString();
128-
return new TimeValue(isoString).toSqliteValue(context);
128+
return new DateTimeValue(isoString).toSqliteValue(context);
129129
} else if (data instanceof mongo.ObjectId) {
130130
return data.toHexString();
131131
} else if (data instanceof mongo.UUID) {

modules/module-postgres/test/src/pg_test.test.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { constructAfterRecord } from '@module/utils/pgwire_utils.js';
22
import * as pgwire from '@powersync/service-jpgwire';
3-
import { applyRowContext, CompatibilityContext, SqliteInputRow, TimeValue } from '@powersync/service-sync-rules';
3+
import {
4+
applyRowContext,
5+
CompatibilityContext,
6+
SqliteInputRow,
7+
DateTimeValue,
8+
TimeValue
9+
} from '@powersync/service-sync-rules';
410
import { describe, expect, test } from 'vitest';
511
import { clearTestDb, connectPgPool, connectPgWire, TEST_URI } from './util.js';
612
import { WalStream } from '@module/replication/WalStream.js';
@@ -158,9 +164,9 @@ VALUES(10, ARRAY['null']::TEXT[]);
158164
expect(transformed[2]).toMatchObject({
159165
id: 3n,
160166
date: '2023-03-06',
161-
time: '15:47:00',
162-
timestamp: new TimeValue('2023-03-06T15:47:00'),
163-
timestamptz: new TimeValue('2023-03-06T13:47:00Z')
167+
time: new TimeValue('15:47:00'),
168+
timestamp: new DateTimeValue('2023-03-06T15:47:00.000000', '2023-03-06 15:47:00'),
169+
timestamptz: new DateTimeValue('2023-03-06T13:47:00.000000Z', '2023-03-06 13:47:00Z')
164170
});
165171

166172
expect(transformed[3]).toMatchObject({
@@ -175,26 +181,26 @@ VALUES(10, ARRAY['null']::TEXT[]);
175181
expect(transformed[4]).toMatchObject({
176182
id: 5n,
177183
date: '0000-01-01',
178-
time: '00:00:00',
179-
timestamp: new TimeValue('0000-01-01T00:00:00'),
180-
timestamptz: new TimeValue('0000-01-01T00:00:00Z')
184+
time: new TimeValue('00:00:00'),
185+
timestamp: new DateTimeValue('0000-01-01T00:00:00'),
186+
timestamptz: new DateTimeValue('0000-01-01T00:00:00Z')
181187
});
182188

183189
expect(transformed[5]).toMatchObject({
184190
id: 6n,
185-
timestamp: new TimeValue('1970-01-01T00:00:00'),
186-
timestamptz: new TimeValue('1970-01-01T00:00:00Z')
191+
timestamp: new DateTimeValue('1970-01-01T00:00:00.000000', '1970-01-01 00:00:00'),
192+
timestamptz: new DateTimeValue('1970-01-01T00:00:00.000000Z', '1970-01-01 00:00:00Z')
187193
});
188194

189195
expect(transformed[6]).toMatchObject({
190196
id: 7n,
191-
timestamp: new TimeValue('9999-12-31T23:59:59'),
192-
timestamptz: new TimeValue('9999-12-31T23:59:59Z')
197+
timestamp: new DateTimeValue('9999-12-31T23:59:59'),
198+
timestamptz: new DateTimeValue('9999-12-31T23:59:59Z')
193199
});
194200

195201
expect(transformed[7]).toMatchObject({
196202
id: 8n,
197-
timestamptz: new TimeValue('0022-02-03T09:13:14Z')
203+
timestamptz: new DateTimeValue('0022-02-03T09:13:14.000000Z', '0022-02-03 09:13:14Z')
198204
});
199205

200206
expect(transformed[8]).toMatchObject({

packages/jpgwire/src/pgwire_types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218
22

33
import { JsonContainer } from '@powersync/service-jsonbig';
4-
import { type DatabaseInputValue } from '@powersync/service-sync-rules';
4+
import { TimeValue, type DatabaseInputValue } from '@powersync/service-sync-rules';
55
import { dateToSqlite, lsnMakeComparable, timestampToSqlite, timestamptzToSqlite } from './util.js';
66

77
export enum PgTypeOid {
@@ -19,6 +19,7 @@ export enum PgTypeOid {
1919
DATE = 1082,
2020
TIMESTAMP = 1114,
2121
TIMESTAMPTZ = 1184,
22+
TIME = 1083,
2223
JSON = 114,
2324
JSONB = 3802,
2425
PG_LSN = 3220
@@ -131,6 +132,8 @@ export class PgType {
131132
return timestampToSqlite(text);
132133
case PgTypeOid.TIMESTAMPTZ:
133134
return timestamptzToSqlite(text);
135+
case PgTypeOid.TIME:
136+
return TimeValue.parse(text);
134137
case PgTypeOid.JSON:
135138
case PgTypeOid.JSONB:
136139
// Don't parse the contents

packages/jpgwire/src/util.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { DEFAULT_CERTS } from './certs.js';
55
import * as pgwire from './pgwire.js';
66
import { PgType } from './pgwire_types.js';
77
import { ConnectOptions } from './socket_adapter.js';
8-
import { DatabaseInputValue, TimeValue } from '@powersync/service-sync-rules';
8+
import { DatabaseInputValue, DateTimeValue } from '@powersync/service-sync-rules';
99

1010
// TODO this is duplicated, but maybe that is ok
1111
export interface NormalizedConnectionConfig {
@@ -222,6 +222,8 @@ export function lsnMakeComparable(text: string) {
222222
return h.padStart(8, '0') + '/' + l.padStart(8, '0');
223223
}
224224

225+
const timeRegex = /^([\d\-]+) ([\d:]+)(\.\d+)?([+-][\d:]+)?$/;
226+
225227
/**
226228
* Convert a postgres timestamptz to a SQLite-compatible/normalized timestamp.
227229
*
@@ -233,17 +235,17 @@ export function lsnMakeComparable(text: string) {
233235
*
234236
* We have specific exceptions for -infinity and infinity.
235237
*/
236-
export function timestamptzToSqlite(source?: string): TimeValue | null {
238+
export function timestamptzToSqlite(source?: string): DateTimeValue | null {
237239
if (source == null) {
238240
return null;
239241
}
240242
// Make compatible with SQLite
241-
const match = /^([\d\-]+) ([\d:]+)(\.\d+)?([+-][\d:]+)$/.exec(source);
243+
const match = timeRegex.exec(source);
242244
if (match == null) {
243245
if (source == 'infinity') {
244-
return new TimeValue('9999-12-31T23:59:59Z');
246+
return new DateTimeValue('9999-12-31T23:59:59Z');
245247
} else if (source == '-infinity') {
246-
return new TimeValue('0000-01-01T00:00:00Z');
248+
return new DateTimeValue('0000-01-01T00:00:00Z');
247249
} else {
248250
return null;
249251
}
@@ -258,10 +260,15 @@ export function timestamptzToSqlite(source?: string): TimeValue | null {
258260
return null;
259261
}
260262

261-
const baseValue = parsed.toISOString().replace('Z', '');
262-
const baseText = `${baseValue}Z`;
263+
const baseValue = parsed.toISOString().replace('.000', '').replace('Z', '');
263264

264-
return new TimeValue(baseText);
265+
// In the new format, we always use ISO 8601. Since Postgres drops zeroes from the fractional seconds, we also pad
266+
// that back to the highest theoretical precision (microseconds). This ensures that sorting returned values as text
267+
// returns them in order of the time value they represent.
268+
//
269+
// In the old format, we keep the sub-second precision only if it's not `.000`.
270+
const missingPrecision = precision?.padEnd(7, '0') ?? '.000000';
271+
return new DateTimeValue(`${baseValue}${missingPrecision}Z`, `${baseValue.replace('T', ' ')}${precision ?? ''}Z`);
265272
}
266273

267274
/**
@@ -271,17 +278,26 @@ export function timestamptzToSqlite(source?: string): TimeValue | null {
271278
*
272279
* https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-SPECIAL-VALUES
273280
*/
274-
export function timestampToSqlite(source?: string): TimeValue | null {
281+
export function timestampToSqlite(source?: string): DateTimeValue | null {
275282
if (source == null) {
276283
return null;
277284
}
278-
if (source == 'infinity') {
279-
return new TimeValue('9999-12-31T23:59:59');
280-
} else if (source == '-infinity') {
281-
return new TimeValue('0000-01-01T00:00:00');
282-
} else {
283-
return new TimeValue(source.replace(' ', 'T'));
285+
286+
const match = timeRegex.exec(source);
287+
if (match == null) {
288+
if (source == 'infinity') {
289+
return new DateTimeValue('9999-12-31T23:59:59');
290+
} else if (source == '-infinity') {
291+
return new DateTimeValue('0000-01-01T00:00:00');
292+
} else {
293+
return null;
294+
}
284295
}
296+
297+
const [_, date, time, precision, __] = match as any;
298+
const missingPrecision = precision?.padEnd(7, '0') ?? '.000000';
299+
300+
return new DateTimeValue(`${date}T${time}${missingPrecision}`, source);
285301
}
286302
/**
287303
* For date, we keep it mostly as-is.

packages/sync-rules/src/quirks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class CompatibilityContext {
5050
*/
5151
readonly explicitlyFixed: Quirk[];
5252

53-
constructor(level: CompatibilityLevel, explicitlyFixed: Quirk[]) {
53+
constructor(level: CompatibilityLevel, explicitlyFixed: Quirk[] = []) {
5454
this.level = level;
5555
this.explicitlyFixed = explicitlyFixed;
5656
}

packages/sync-rules/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { TablePattern } from './TablePattern.js';
66
import { toSyncRulesParameters } from './utils.js';
77
import { BucketPriority } from './BucketDescription.js';
88
import { ParameterLookup } from './BucketParameterQuerier.js';
9-
import { TimeValue } from './types/time.js';
9+
import { DateTimeValue } from './types/time.js';
1010
import { CustomSqliteValue } from './types/custom_sqlite_value.js';
1111
import { CompatibilityContext } from './quirks.js';
1212

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SqliteValueType } from '../ExpressionType.js';
22
import { CompatibilityContext, Quirk } from '../quirks.js';
3+
import { SqliteValue } from '../types.js';
34
import { CustomSqliteValue } from './custom_sqlite_value.js';
45

56
/**
@@ -9,18 +10,19 @@ import { CustomSqliteValue } from './custom_sqlite_value.js';
910
* This is not ISO 6801 compatible, but changing it would be breaking existing users. So, this option is opt-in and
1011
* disabled by default until a major upgrade.
1112
*/
12-
export class TimeValue extends CustomSqliteValue {
13+
export class DateTimeValue extends CustomSqliteValue {
1314
// YYYY-MM-DDThh:mm:ss.sss / YYYY-MM-DDThh:mm:ss.sssZ
14-
readonly iso8601Representation: string;
1515

16-
constructor(iso8601Representation: string) {
16+
constructor(
17+
readonly iso8601Representation: string,
18+
private readonly fixedLegacyRepresentation: string | undefined = undefined
19+
) {
1720
super();
18-
this.iso8601Representation = iso8601Representation;
1921
}
2022

2123
// YYYY-MM-DD hh:mm:ss.sss / YYYY-MM-DD hh:mm:ss.sssZ
2224
public get legacyRepresentation(): string {
23-
return this.iso8601Representation.replace('T', ' ');
25+
return this.fixedLegacyRepresentation ?? this.iso8601Representation.replace('T', ' ');
2426
}
2527

2628
get sqliteType(): SqliteValueType {
@@ -31,3 +33,41 @@ export class TimeValue extends CustomSqliteValue {
3133
return context.isFixed(Quirk.nonIso8601Timestampts) ? this.iso8601Representation : this.legacyRepresentation;
3234
}
3335
}
36+
37+
/**
38+
* In old versions of the sync service, time values didn't consistently contain a sub-second interval.
39+
*
40+
* A value like `12:13:14.156789` would be represented as-is, but `12:13:14.000000` would be synced as `12:13:14`. This
41+
* is undesirable because it means that sorting values alphabetically doesn't preserve their value.
42+
*/
43+
export class TimeValue extends CustomSqliteValue {
44+
constructor(
45+
readonly timeSeconds: string,
46+
readonly fraction: string | undefined = undefined
47+
) {
48+
super();
49+
}
50+
51+
static parse(value: string): TimeValue | null {
52+
const match = /^([\d:]+)(\.\d+)?$/.exec(value);
53+
if (match == null) {
54+
return null;
55+
}
56+
57+
const [_, timeSeconds, fraction] = match as any;
58+
return new TimeValue(timeSeconds, fraction);
59+
}
60+
61+
toSqliteValue(context: CompatibilityContext): SqliteValue {
62+
if (context.isFixed(Quirk.nonIso8601Timestampts)) {
63+
const fraction = this.fraction?.padEnd(7, '0') ?? '.000000';
64+
return `${this.timeSeconds}${fraction}`;
65+
} else {
66+
return `${this.timeSeconds}${this.fraction ?? ''}`;
67+
}
68+
}
69+
70+
get sqliteType(): SqliteValueType {
71+
return 'text';
72+
}
73+
}

packages/sync-rules/test/src/quirks.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { describe, expect, test } from 'vitest';
2-
import { CustomSqliteValue, SqlSyncRules, TimeValue, toSyncRulesValue } from '../../src/index.js';
2+
import { CustomSqliteValue, SqlSyncRules, DateTimeValue, toSyncRulesValue } from '../../src/index.js';
33

44
import { ASSETS, PARSE_OPTIONS } from './util.js';
55

66
describe('handling historical quirks', () => {
77
describe('timestamps', () => {
8-
const value = new TimeValue('2025-08-19T09:21:00Z');
8+
const value = new DateTimeValue('2025-08-19T09:21:00Z');
99

1010
test('uses old format by default', () => {
1111
const rules = SqlSyncRules.fromYaml(
@@ -77,7 +77,7 @@ fixed_quirks:
7777
});
7878

7979
test('arrays', () => {
80-
const data = toSyncRulesValue(['static value', new TimeValue('2025-08-19T09:21:00Z')]);
80+
const data = toSyncRulesValue(['static value', new DateTimeValue('2025-08-19T09:21:00Z')]);
8181

8282
for (const withFixedQuirk of [false, true]) {
8383
let syncRules = `

packages/sync-rules/test/src/utils.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,43 @@ import {
22
applyValueContext,
33
CompatibilityContext,
44
evaluateOperator,
5+
DateTimeValue,
6+
toSyncRulesValue,
57
TimeValue,
6-
toSyncRulesValue
8+
CompatibilityLevel
79
} from '../../src/index.js';
810
import { describe, expect, test } from 'vitest';
911

1012
describe('toSyncRulesValue', () => {
1113
test('custom value', () => {
1214
expect(
1315
applyValueContext(
14-
toSyncRulesValue([1n, 'two', [new TimeValue('2025-08-19T00:00:00')]]),
16+
toSyncRulesValue([1n, 'two', [new DateTimeValue('2025-08-19T00:00:00')]]),
1517
CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY
1618
)
1719
).toStrictEqual('[1,"two",["2025-08-19 00:00:00"]]');
1820

1921
expect(
2022
applyValueContext(
21-
toSyncRulesValue({ foo: { bar: new TimeValue('2025-08-19T00:00:00') } }),
23+
toSyncRulesValue({ foo: { bar: new DateTimeValue('2025-08-19T00:00:00') } }),
2224
CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY
2325
)
2426
).toStrictEqual('{"foo":{"bar":"2025-08-19 00:00:00"}}');
2527
});
28+
29+
test('time value', () => {
30+
expect(
31+
TimeValue.parse('12:13:14')?.toSqliteValue(new CompatibilityContext(CompatibilityLevel.SYNC_STREAMS))
32+
).toStrictEqual('12:13:14.000000');
33+
expect(
34+
TimeValue.parse('12:13:14')?.toSqliteValue(new CompatibilityContext(CompatibilityLevel.LEGACY))
35+
).toStrictEqual('12:13:14');
36+
37+
expect(
38+
TimeValue.parse('12:13:14.15')?.toSqliteValue(new CompatibilityContext(CompatibilityLevel.SYNC_STREAMS))
39+
).toStrictEqual('12:13:14.150000');
40+
expect(
41+
TimeValue.parse('12:13:14.15')?.toSqliteValue(new CompatibilityContext(CompatibilityLevel.LEGACY))
42+
).toStrictEqual('12:13:14.15');
43+
});
2644
});

0 commit comments

Comments
 (0)