From 84b680f86b9b12c3a676d072651e9ebc481c0ecf Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 7 Nov 2025 10:51:53 +0100 Subject: [PATCH 1/5] Add option to restrict datetime precision --- .../src/replication/MongoRelation.ts | 13 ++- .../src/common/mysql-to-sqlite.ts | 14 ++- .../src/replication/MySQLConnectionManager.ts | 4 +- packages/jpgwire/src/pgwire_types.ts | 14 ++- packages/jpgwire/src/util.ts | 18 +-- packages/sync-rules/src/SqlSyncRules.ts | 22 +++- packages/sync-rules/src/compatibility.ts | 42 ++++++- packages/sync-rules/src/json_schema.ts | 6 +- packages/sync-rules/src/request_functions.ts | 2 +- packages/sync-rules/src/types/time.ts | 105 +++++++++++++++--- .../sync-rules/test/src/compatibility.test.ts | 64 ++++++++++- .../sync-rules/test/src/sql_functions.test.ts | 4 +- packages/sync-rules/test/src/streams.test.ts | 2 +- .../src/table_valued_function_queries.test.ts | 8 +- .../sync-rules/test/src/types/time.test.ts | 88 +++++++++++++++ packages/sync-rules/test/src/utils.test.ts | 31 +++--- 16 files changed, 374 insertions(+), 63 deletions(-) create mode 100644 packages/sync-rules/test/src/types/time.test.ts diff --git a/modules/module-mongodb/src/replication/MongoRelation.ts b/modules/module-mongodb/src/replication/MongoRelation.ts index d1c6a0ed3..7ca0e51b8 100644 --- a/modules/module-mongodb/src/replication/MongoRelation.ts +++ b/modules/module-mongodb/src/replication/MongoRelation.ts @@ -8,7 +8,9 @@ import { CustomSqliteValue, SqliteInputRow, SqliteInputValue, - DateTimeValue + DateTimeValue, + DateTimeSourceOptions, + TimeValuePrecision } from '@powersync/service-sync-rules'; import { ErrorCode, ServiceError } from '@powersync/lib-services-framework'; @@ -69,7 +71,7 @@ export function toMongoSyncRulesValue(data: any): SqliteInputValue { return data.toHexString(); } else if (data instanceof Date) { const isoString = data.toISOString(); - return new DateTimeValue(isoString); + return new DateTimeValue(isoString, undefined, mongoTimeOptions); } else if (data instanceof mongo.Binary) { return new Uint8Array(data.buffer); } else if (data instanceof mongo.Long) { @@ -122,7 +124,7 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an return data; } else if (data instanceof Date) { const isoString = data.toISOString(); - return new DateTimeValue(isoString).toSqliteValue(context); + return new DateTimeValue(isoString, undefined, mongoTimeOptions).toSqliteValue(context); } else if (data instanceof mongo.ObjectId) { return data.toHexString(); } else if (data instanceof mongo.UUID) { @@ -195,3 +197,8 @@ export async function createCheckpoint( await session.endSession(); } } + +const mongoTimeOptions: DateTimeSourceOptions = { + subSecondPrecision: TimeValuePrecision.milliseconds, + defaultSubSecondPrecision: TimeValuePrecision.milliseconds +}; diff --git a/modules/module-mysql/src/common/mysql-to-sqlite.ts b/modules/module-mysql/src/common/mysql-to-sqlite.ts index e60e928c4..ef9bb7e28 100644 --- a/modules/module-mysql/src/common/mysql-to-sqlite.ts +++ b/modules/module-mysql/src/common/mysql-to-sqlite.ts @@ -132,13 +132,15 @@ export function toSQLiteRow( case mysql.Types.TIMESTAMP: case ADDITIONAL_MYSQL_TYPES.TIMESTAMP2: { - const date = row[key] as Date; - if (isNaN(date.getTime())) { + const formattedDate = row[key] as string; + const parsed = new Date(formattedDate); + + if (isNaN(parsed.getTime())) { // Invalid dates, such as 2024-00-00. // we can't do anything meaningful with this, so just use null. result[key] = null; } else { - result[key] = date.toISOString(); + result[key] = new sync_rules.DateTimeValue(formattedDate, parsed.toISOString(), mySqlDateTimeOptions); } } break; @@ -241,3 +243,9 @@ export function toExpressionTypeFromMySQLType(mysqlType: string | undefined): Ex return ExpressionType.TEXT; } } + +// We can provide microsecond accuracy, but for backwards compatibility we only offer millisecond accuracy by default. +const mySqlDateTimeOptions: sync_rules.DateTimeSourceOptions = { + subSecondPrecision: sync_rules.TimeValuePrecision.microseconds, + defaultSubSecondPrecision: sync_rules.TimeValuePrecision.milliseconds +}; diff --git a/modules/module-mysql/src/replication/MySQLConnectionManager.ts b/modules/module-mysql/src/replication/MySQLConnectionManager.ts index b648ab262..a2fdacc79 100644 --- a/modules/module-mysql/src/replication/MySQLConnectionManager.ts +++ b/modules/module-mysql/src/replication/MySQLConnectionManager.ts @@ -48,7 +48,9 @@ export class MySQLConnectionManager { host: this.options.hostname, port: this.options.port, user: this.options.username, - password: this.options.password + password: this.options.password, + // We want to avoid parsing date/time values to Date, because that drops sub-millisecond precision. + dateStrings: true }); this.binlogListeners.push(listener); diff --git a/packages/jpgwire/src/pgwire_types.ts b/packages/jpgwire/src/pgwire_types.ts index 97fe442ba..bdcbb476e 100644 --- a/packages/jpgwire/src/pgwire_types.ts +++ b/packages/jpgwire/src/pgwire_types.ts @@ -1,7 +1,12 @@ // Adapted from https://github.com/kagis/pgwire/blob/0dc927f9f8990a903f238737326e53ba1c8d094f/mod.js#L2218 import { JsonContainer } from '@powersync/service-jsonbig'; -import { type DatabaseInputValue, TimeValue } from '@powersync/service-sync-rules'; +import { + type DatabaseInputValue, + DateTimeSourceOptions, + TimeValue, + TimeValuePrecision +} from '@powersync/service-sync-rules'; import { dateToSqlite, lsnMakeComparable, timestampToSqlite, timestamptzToSqlite } from './util.js'; import { StructureParser } from './structure_parser.js'; @@ -134,7 +139,7 @@ export class PgType { case PgTypeOid.TIMESTAMPTZ: return timestamptzToSqlite(text); case PgTypeOid.TIME: - return TimeValue.parse(text); + return TimeValue.parse(text, postgresTimeOptions); case PgTypeOid.JSON: case PgTypeOid.JSONB: // Don't parse the contents @@ -173,3 +178,8 @@ export class PgType { return '\\x' + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } } + +export const postgresTimeOptions: DateTimeSourceOptions = Object.freeze({ + subSecondPrecision: TimeValuePrecision.milliseconds, + defaultSubSecondPrecision: TimeValuePrecision.milliseconds +}); diff --git a/packages/jpgwire/src/util.ts b/packages/jpgwire/src/util.ts index e11844b2b..c666a2d45 100644 --- a/packages/jpgwire/src/util.ts +++ b/packages/jpgwire/src/util.ts @@ -3,7 +3,7 @@ import * as net from 'node:net'; import * as tls from 'node:tls'; import { DEFAULT_CERTS } from './certs.js'; import * as pgwire from './pgwire.js'; -import { PgType } from './pgwire_types.js'; +import { PgType, postgresTimeOptions } from './pgwire_types.js'; import { ConnectOptions } from './socket_adapter.js'; import { DatabaseInputValue, DateTimeValue } from '@powersync/service-sync-rules'; @@ -243,9 +243,9 @@ export function timestamptzToSqlite(source?: string): DateTimeValue | null { const match = timeRegex.exec(source); if (match == null) { if (source == 'infinity') { - return new DateTimeValue('9999-12-31T23:59:59Z'); + return new DateTimeValue('9999-12-31T23:59:59Z', undefined, postgresTimeOptions); } else if (source == '-infinity') { - return new DateTimeValue('0000-01-01T00:00:00Z'); + return new DateTimeValue('0000-01-01T00:00:00Z', undefined, postgresTimeOptions); } else { return null; } @@ -268,7 +268,11 @@ export function timestamptzToSqlite(source?: string): DateTimeValue | null { // // In the old format, we keep the sub-second precision only if it's not `.000`. const missingPrecision = precision?.padEnd(7, '0') ?? '.000000'; - return new DateTimeValue(`${baseValue}${missingPrecision}Z`, `${baseValue.replace('T', ' ')}${precision ?? ''}Z`); + return new DateTimeValue( + `${baseValue}${missingPrecision}Z`, + `${baseValue.replace('T', ' ')}${precision ?? ''}Z`, + postgresTimeOptions + ); } /** @@ -286,9 +290,9 @@ export function timestampToSqlite(source?: string): DateTimeValue | null { const match = timeRegex.exec(source); if (match == null) { if (source == 'infinity') { - return new DateTimeValue('9999-12-31T23:59:59'); + return new DateTimeValue('9999-12-31T23:59:59', undefined, postgresTimeOptions); } else if (source == '-infinity') { - return new DateTimeValue('0000-01-01T00:00:00'); + return new DateTimeValue('0000-01-01T00:00:00', undefined, postgresTimeOptions); } else { return null; } @@ -297,7 +301,7 @@ export function timestampToSqlite(source?: string): DateTimeValue | null { const [_, date, time, precision, __] = match as any; const missingPrecision = precision?.padEnd(7, '0') ?? '.000000'; - return new DateTimeValue(`${date}T${time}${missingPrecision}`, source); + return new DateTimeValue(`${date}T${time}${missingPrecision}`, source, postgresTimeOptions); } /** * For date, we keep it mostly as-is. diff --git a/packages/sync-rules/src/SqlSyncRules.ts b/packages/sync-rules/src/SqlSyncRules.ts index 6c741d0bf..216317702 100644 --- a/packages/sync-rules/src/SqlSyncRules.ts +++ b/packages/sync-rules/src/SqlSyncRules.ts @@ -30,7 +30,12 @@ import { } from './types.js'; import { BucketSource } from './BucketSource.js'; import { syncStreamFromSql } from './streams/from_sql.js'; -import { CompatibilityContext, CompatibilityEdition, CompatibilityOption } from './compatibility.js'; +import { + CompatibilityContext, + CompatibilityEdition, + CompatibilityOption, + TimeValuePrecision +} from './compatibility.js'; import { applyRowContext } from './utils.js'; const ACCEPT_POTENTIALLY_DANGEROUS_QUERIES = Symbol('ACCEPT_POTENTIALLY_DANGEROUS_QUERIES'); @@ -155,12 +160,17 @@ export class SqlSyncRules implements SyncRules { if (declaredOptions != null) { const edition = (declaredOptions.get('edition') ?? CompatibilityEdition.LEGACY) as CompatibilityEdition; const options = new Map(); + let maxTimeValuePrecision: TimeValuePrecision | undefined = undefined; for (const entry of declaredOptions.items) { const { key: { value: key }, value: { value } - } = entry as { key: Scalar; value: Scalar }; + } = entry as { key: Scalar; value: Scalar }; + + if (key == 'timestamp_max_precision') { + maxTimeValuePrecision = TimeValuePrecision.byName[value]; + } const option = CompatibilityOption.byName[key]; if (option) { @@ -168,7 +178,13 @@ export class SqlSyncRules implements SyncRules { } } - compatibility = new CompatibilityContext(edition, options); + compatibility = new CompatibilityContext({ edition, overrides: options, maxTimeValuePrecision }); + if (maxTimeValuePrecision && !compatibility.isEnabled(CompatibilityOption.timestampsIso8601)) { + rules.errors.push( + new YamlError(new Error(`'timestamp_max_precision' requires 'timestamps_iso8601' to be enabled.`)) + ); + } + rules.compatibility = compatibility; } diff --git a/packages/sync-rules/src/compatibility.ts b/packages/sync-rules/src/compatibility.ts index 24ba9738b..7459279a9 100644 --- a/packages/sync-rules/src/compatibility.ts +++ b/packages/sync-rules/src/compatibility.ts @@ -3,6 +3,23 @@ export enum CompatibilityEdition { SYNC_STREAMS = 2 } +export class TimeValuePrecision { + private constructor( + readonly name: string, + readonly subSecondDigits: number + ) {} + + static seconds = new TimeValuePrecision('seconds', 0); + static milliseconds = new TimeValuePrecision('milliseconds', 3); + static microseconds = new TimeValuePrecision('microseconds', 6); + + static byName: Record = Object.freeze({ + seconds: this.seconds, + milliseconds: this.milliseconds, + microseconds: this.microseconds + }); +} + /** * A historical issue of the PowerSync service that can only be changed in a backwards-incompatible manner. * @@ -48,6 +65,12 @@ export class CompatibilityOption { }); } +export interface CompatibilityContextOptions { + edition: CompatibilityEdition; + overrides?: Map; + maxTimeValuePrecision?: TimeValuePrecision; +} + export class CompatibilityContext { /** * The general compatibility level we're operating under. @@ -62,9 +85,18 @@ export class CompatibilityContext { */ readonly overrides: Map; - constructor(edition: CompatibilityEdition, overrides?: Map) { - this.edition = edition; - this.overrides = overrides ?? new Map(); + /** + * A limit for the precision the sync service uses to encode time values. + * + * This can be set to e.g. `milliseconds` to only emit three digits of sub-second precision even if the source + * database uses microseconds natively. + */ + readonly maxTimeValuePrecision: TimeValuePrecision | null; + + constructor(options: CompatibilityContextOptions) { + this.edition = options.edition; + this.overrides = options.overrides ?? new Map(); + this.maxTimeValuePrecision = options.maxTimeValuePrecision ?? null; } isEnabled(option: CompatibilityOption) { @@ -74,5 +106,7 @@ export class CompatibilityContext { /** * A {@link CompatibilityContext} in which no fixes are applied. */ - static FULL_BACKWARDS_COMPATIBILITY: CompatibilityContext = new CompatibilityContext(CompatibilityEdition.LEGACY); + static FULL_BACKWARDS_COMPATIBILITY: CompatibilityContext = new CompatibilityContext({ + edition: CompatibilityEdition.LEGACY + }); } diff --git a/packages/sync-rules/src/json_schema.ts b/packages/sync-rules/src/json_schema.ts index 930ea7123..6c593c5f3 100644 --- a/packages/sync-rules/src/json_schema.ts +++ b/packages/sync-rules/src/json_schema.ts @@ -1,5 +1,5 @@ import ajvModule from 'ajv'; -import { CompatibilityEdition, CompatibilityOption } from './compatibility.js'; +import { CompatibilityEdition, CompatibilityOption, TimeValuePrecision } from './compatibility.js'; // Hack to make this work both in NodeJS and a browser const Ajv = ajvModule.default ?? ajvModule; const ajv = new Ajv({ allErrors: true, verbose: true }); @@ -119,6 +119,10 @@ export const syncRulesSchema: ajvModule.Schema = { minimum: CompatibilityEdition.LEGACY, exclusiveMaximum: CompatibilityEdition.SYNC_STREAMS + 1 }, + timestamp_max_precision: { + type: 'string', + enum: Object.values(TimeValuePrecision.byName).map((e) => e.name) + }, ...Object.fromEntries( Object.entries(CompatibilityOption.byName).map((e) => { return [ diff --git a/packages/sync-rules/src/request_functions.ts b/packages/sync-rules/src/request_functions.ts index c6a79ae4f..585916ec3 100644 --- a/packages/sync-rules/src/request_functions.ts +++ b/packages/sync-rules/src/request_functions.ts @@ -23,7 +23,7 @@ export interface SqlParameterFunction { } const jsonExtractFromRecord = generateSqlFunctions( - new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS) + new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }) ).jsonExtractFromRecord; /** * Defines a `parameters` function and a `parameter` function. diff --git a/packages/sync-rules/src/types/time.ts b/packages/sync-rules/src/types/time.ts index e4a1188fa..8fbb9734b 100644 --- a/packages/sync-rules/src/types/time.ts +++ b/packages/sync-rules/src/types/time.ts @@ -1,8 +1,26 @@ import { SqliteValueType } from '../ExpressionType.js'; -import { CompatibilityContext, CompatibilityOption } from '../compatibility.js'; +import { CompatibilityContext, CompatibilityOption, TimeValuePrecision } from '../compatibility.js'; import { SqliteValue } from '../types.js'; import { CustomSqliteValue } from './custom_sqlite_value.js'; +export interface DateTimeSourceOptions { + /** + * The amount of sub-second digits provided by the source database. + * + * We can't infer this by parsing the iso representation alone, since trailing zeroes might be omitted from the + * representation. + */ + subSecondPrecision: TimeValuePrecision; + /** + * The default precision to use when rendering {@link DateTimeValue}s in a compatibility context that doesn't have a + * {@link CompatibilityContext.maxTimeValuePrecision} set. + * + * This is usually the same as {@link subSecondPrecision}, except for MySQL, where we can provide microseconds + * precision but default to milliseconds. + */ + defaultSubSecondPrecision: TimeValuePrecision; +} + /** * In old versions of the sync service, timestamp values were formatted with a space between the date and time * components. @@ -15,7 +33,8 @@ export class DateTimeValue extends CustomSqliteValue { constructor( readonly iso8601Representation: string, - private readonly fixedLegacyRepresentation: string | undefined = undefined + private readonly fixedLegacyRepresentation: string | undefined = undefined, + private readonly options: DateTimeSourceOptions ) { super(); } @@ -30,9 +49,27 @@ export class DateTimeValue extends CustomSqliteValue { } toSqliteValue(context: CompatibilityContext) { - return context.isEnabled(CompatibilityOption.timestampsIso8601) - ? this.iso8601Representation - : this.legacyRepresentation; + if (context.isEnabled(CompatibilityOption.timestampsIso8601)) { + return renderSubseconds( + () => { + // Match the `.123` subsecond part and/or a `Z` suffix. + const matchSubSeconds = /(?:\.(\d+))?([zZ]?)$/.exec(this.iso8601Representation); + if (matchSubSeconds == null || matchSubSeconds[0].length == 0) { + return [this.iso8601Representation, '', '']; + } else { + return [ + this.iso8601Representation.slice(0, -matchSubSeconds[0].length), + matchSubSeconds[1], + matchSubSeconds[2] + ]; + } + }, + this.options, + context + ); + } else { + return this.legacyRepresentation; + } } } @@ -43,29 +80,33 @@ export class DateTimeValue extends CustomSqliteValue { * is undesirable because it means that sorting values alphabetically doesn't preserve their value. */ export class TimeValue extends CustomSqliteValue { - constructor( + private constructor( readonly timeSeconds: string, - readonly fraction: string | undefined = undefined + readonly fraction: string, + private readonly options: DateTimeSourceOptions ) { super(); } - static parse(value: string): TimeValue | null { - const match = /^([\d:]+)(\.\d+)?$/.exec(value); + static parse(value: string, options: DateTimeSourceOptions): TimeValue | null { + const match = /^([\d:]+)(?:\.(\d+))?$/.exec(value); if (match == null) { return null; } const [_, timeSeconds, fraction] = match as any; - return new TimeValue(timeSeconds, fraction); + return new TimeValue(timeSeconds, fraction, options); } toSqliteValue(context: CompatibilityContext): SqliteValue { if (context.isEnabled(CompatibilityOption.timestampsIso8601)) { - const fraction = this.fraction?.padEnd(7, '0') ?? '.000000'; - return `${this.timeSeconds}${fraction}`; + return renderSubseconds(() => [this.timeSeconds, this.fraction ?? '', ''], this.options, context); } else { - return `${this.timeSeconds}${this.fraction ?? ''}`; + if (this.fraction) { + return `${this.timeSeconds}.${this.fraction}`; + } else { + return this.timeSeconds; + } } } @@ -73,3 +114,41 @@ export class TimeValue extends CustomSqliteValue { return 'text'; } } + +/** + * Renders a time value with a designated precision. + * + * @param split Returns the original time value, split into the part before the sub-second fraction and the subsecond + * fraction. The `.` separator should not be part of either string. + * @param options Information about precision overred by the source database. + * @param context The {@link CompatibilityContext} to take into consideration. + * @returns The rendered value. + */ +function renderSubseconds( + split: () => [string, string, string], + options: DateTimeSourceOptions, + context: CompatibilityContext +) { + const maxPrecision = context.maxTimeValuePrecision ?? options.defaultSubSecondPrecision; + const maxSubSecondDigits = Math.min(maxPrecision.subSecondDigits, options.subSecondPrecision.subSecondDigits); + + // Note: We are deliberately not rounding here, we always trim precision away. Rounding would require a parsed date + // with subsecond precision, which is just painful in JS. Maybe with the temporal API in Node 25... + let [withoutSubseconds, subseconds, suffix] = split(); + if (maxPrecision == TimeValuePrecision.seconds) { + // Avoid a trailing `.` if we only care about seconds. + return `${withoutSubseconds}${suffix}`; + } + + if (subseconds.length > maxSubSecondDigits) { + // Trim unwanted precision. + subseconds = subseconds.substring(0, maxSubSecondDigits); + } else if (subseconds.length < maxSubSecondDigits) { + // Let's say we had a source database stripping trailing zeroes from the subsecond field. Perhaps the + // subSecondPrecision is generally micros, but one value has .123456 and one has .1234 instead of .123400. For + // consistency, we pad those value. + subseconds = subseconds.padEnd(maxSubSecondDigits, '0'); + } + + return `${withoutSubseconds}.${subseconds}${suffix}`; +} diff --git a/packages/sync-rules/test/src/compatibility.test.ts b/packages/sync-rules/test/src/compatibility.test.ts index e1c4a7440..67a248b69 100644 --- a/packages/sync-rules/test/src/compatibility.test.ts +++ b/packages/sync-rules/test/src/compatibility.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from 'vitest'; -import { SqlSyncRules, DateTimeValue, toSyncRulesValue, SqliteInputRow } from '../../src/index.js'; +import { SqlSyncRules, DateTimeValue, toSyncRulesValue, TimeValuePrecision } from '../../src/index.js'; import { ASSETS, identityBucketTransformer, normalizeQuerierOptions, PARSE_OPTIONS } from './util.js'; describe('compatibility options', () => { describe('timestamps', () => { - const value = new DateTimeValue('2025-08-19T09:21:00Z'); + const value = new DateTimeValue('2025-08-19T09:21:00Z', undefined, { + subSecondPrecision: TimeValuePrecision.seconds, + defaultSubSecondPrecision: TimeValuePrecision.seconds + }); test('uses old format by default', () => { const rules = SqlSyncRules.fromYaml( @@ -190,7 +193,10 @@ config: bucketIdTransformer: SqlSyncRules.versionedBucketIdTransformer('1'), record: rules.applyRowContext({ id: 'id', - description: new DateTimeValue('2025-08-19T09:21:00Z') + description: new DateTimeValue('2025-08-19T09:21:00Z', undefined, { + subSecondPrecision: TimeValuePrecision.seconds, + defaultSubSecondPrecision: TimeValuePrecision.seconds + }) }) }) ).toStrictEqual([ @@ -268,7 +274,13 @@ config: }); test('arrays', () => { - const data = toSyncRulesValue(['static value', new DateTimeValue('2025-08-19T09:21:00Z')]); + const data = toSyncRulesValue([ + 'static value', + new DateTimeValue('2025-08-19T09:21:00Z', undefined, { + subSecondPrecision: TimeValuePrecision.seconds, + defaultSubSecondPrecision: TimeValuePrecision.seconds + }) + ]); for (const withFixedQuirk of [false, true]) { let syncRules = ` @@ -323,4 +335,48 @@ config: ]); } }); + + describe('max datetime precision', () => { + test('is not supported in edition 1', () => { + expect(() => { + SqlSyncRules.fromYaml( + ` +bucket_definitions: + mybucket: + data: + - SELECT id, description FROM assets + +config: + timestamp_max_precision: seconds + `, + PARSE_OPTIONS + ); + }).toThrow(`'timestamp_max_precision' requires 'timestamps_iso8601' to be enabled`); + }); + + test('can set max precision', () => { + const rules = SqlSyncRules.fromYaml( + ` +bucket_definitions: + mybucket: + data: + - SELECT id, description FROM assets + +config: + edition: 2 + timestamp_max_precision: seconds + `, + PARSE_OPTIONS + ); + + expect( + rules.applyRowContext({ + a: new DateTimeValue('2025-11-07T10:45:03.123Z', undefined, { + subSecondPrecision: TimeValuePrecision.microseconds, + defaultSubSecondPrecision: TimeValuePrecision.microseconds + }) + }) + ).toStrictEqual({ a: '2025-11-07T10:45:03Z' }); + }); + }); }); diff --git a/packages/sync-rules/test/src/sql_functions.test.ts b/packages/sync-rules/test/src/sql_functions.test.ts index 9691e4195..271444495 100644 --- a/packages/sync-rules/test/src/sql_functions.test.ts +++ b/packages/sync-rules/test/src/sql_functions.test.ts @@ -62,7 +62,9 @@ describe('SQL functions', () => { }); test('fixed json extract', () => { - const { jsonExtract, callable } = generateSqlFunctions(new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)); + const { jsonExtract, callable } = generateSqlFunctions( + new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }) + ); expect(callable.json_extract(`{"foo": null}`, '$.foo')).toBeNull(); expect(jsonExtract(`{"foo": null}`, '$.foo', '->>')).toBeNull(); diff --git a/packages/sync-rules/test/src/streams.test.ts b/packages/sync-rules/test/src/streams.test.ts index 855f21f88..eddeec48e 100644 --- a/packages/sync-rules/test/src/streams.test.ts +++ b/packages/sync-rules/test/src/streams.test.ts @@ -777,7 +777,7 @@ const schema = new StaticSchema([ const options: StreamParseOptions = { schema: schema, ...PARSE_OPTIONS, - compatibility: new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS) + compatibility: new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }) }; const bucketIdTransformer = SqlSyncRules.versionedBucketIdTransformer('1'); diff --git a/packages/sync-rules/test/src/table_valued_function_queries.test.ts b/packages/sync-rules/test/src/table_valued_function_queries.test.ts index 997270381..2ead7b2be 100644 --- a/packages/sync-rules/test/src/table_valued_function_queries.test.ts +++ b/packages/sync-rules/test/src/table_valued_function_queries.test.ts @@ -45,10 +45,10 @@ describe('table-valued function queries', () => { { ...PARSE_OPTIONS, accept_potentially_dangerous_queries: true, - compatibility: new CompatibilityContext( - CompatibilityEdition.LEGACY, - new Map([[CompatibilityOption.fixedJsonExtract, true]]) - ) + compatibility: new CompatibilityContext({ + edition: CompatibilityEdition.LEGACY, + overrides: new Map([[CompatibilityOption.fixedJsonExtract, true]]) + }) }, '1' ) as StaticSqlParameterQuery; diff --git a/packages/sync-rules/test/src/types/time.test.ts b/packages/sync-rules/test/src/types/time.test.ts new file mode 100644 index 000000000..49c8f8404 --- /dev/null +++ b/packages/sync-rules/test/src/types/time.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; +import { CompatibilityContext, DateTimeValue, TimeValuePrecision } from '../../../src/index.js'; + +describe('date formatting', () => { + describe('default precision', () => { + const context = new CompatibilityContext({ edition: 2 }); + + test('mysql', () => { + // MySQL provides six digits of precision, but should only emit three by default for backwards compatibility. + expect( + new DateTimeValue('2025-11-07T09:31:12.123456', undefined, mysqlOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12.123'); + }); + + test('postgres', () => { + expect( + new DateTimeValue('2025-11-07T09:31:12.123456', undefined, postgresOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12.123456'); + }); + + test('mongo', () => { + expect( + new DateTimeValue('2025-11-07T09:31:12.123', undefined, mongoOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12.123'); + }); + }); + + test('higher max precision than source', () => { + const context = new CompatibilityContext({ edition: 2, maxTimeValuePrecision: TimeValuePrecision.microseconds }); + + expect(new DateTimeValue('2025-11-07T09:31:12.123', undefined, mongoOptions).toSqliteValue(context)).toStrictEqual( + '2025-11-07T09:31:12.123' + ); + + expect(new DateTimeValue('2025-11-07T09:31:12', undefined, mongoOptions).toSqliteValue(context)).toStrictEqual( + '2025-11-07T09:31:12.000' + ); + }); + + test('enable higher precision for mysql', () => { + const context = new CompatibilityContext({ edition: 2, maxTimeValuePrecision: TimeValuePrecision.microseconds }); + + expect( + new DateTimeValue('2025-11-07T09:31:12.123456', undefined, mysqlOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12.123456'); + }); + + test('reduce max precision to millis', () => { + const context = new CompatibilityContext({ edition: 2, maxTimeValuePrecision: TimeValuePrecision.milliseconds }); + + expect( + new DateTimeValue('2025-11-07T09:31:12.123456', undefined, postgresOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12.123'); + expect( + new DateTimeValue('2025-11-07T09:31:12.123456Z', undefined, postgresOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12.123Z'); + }); + + test('reduce max precision to seconds', () => { + const context = new CompatibilityContext({ edition: 2, maxTimeValuePrecision: TimeValuePrecision.seconds }); + + expect( + new DateTimeValue('2025-11-07T09:31:12.123456', undefined, postgresOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12'); + expect( + new DateTimeValue('2025-11-07T09:31:12.123456Z', undefined, postgresOptions).toSqliteValue(context) + ).toStrictEqual('2025-11-07T09:31:12Z'); + + expect(new DateTimeValue('2025-11-07T09:31:12.123', undefined, mongoOptions).toSqliteValue(context)).toStrictEqual( + '2025-11-07T09:31:12' + ); + }); +}); + +const mysqlOptions = { + subSecondPrecision: TimeValuePrecision.microseconds, + defaultSubSecondPrecision: TimeValuePrecision.milliseconds +}; + +const postgresOptions = { + subSecondPrecision: TimeValuePrecision.microseconds, + defaultSubSecondPrecision: TimeValuePrecision.microseconds +}; + +const mongoOptions = { + subSecondPrecision: TimeValuePrecision.milliseconds, + defaultSubSecondPrecision: TimeValuePrecision.milliseconds +}; diff --git a/packages/sync-rules/test/src/utils.test.ts b/packages/sync-rules/test/src/utils.test.ts index 648a5e7ef..9f6c9cd85 100644 --- a/packages/sync-rules/test/src/utils.test.ts +++ b/packages/sync-rules/test/src/utils.test.ts @@ -4,40 +4,41 @@ import { DateTimeValue, toSyncRulesValue, TimeValue, - CompatibilityEdition + CompatibilityEdition, + DateTimeSourceOptions, + TimeValuePrecision } from '../../src/index.js'; import { describe, expect, test } from 'vitest'; describe('toSyncRulesValue', () => { + const legacy = new CompatibilityContext({ edition: 1 }); + const syncStreams = new CompatibilityContext({ edition: 2 }); + const sourceOptions: DateTimeSourceOptions = { + subSecondPrecision: TimeValuePrecision.milliseconds, + defaultSubSecondPrecision: TimeValuePrecision.milliseconds + }; + test('custom value', () => { expect( applyValueContext( - toSyncRulesValue([1n, 'two', [new DateTimeValue('2025-08-19T00:00:00')]]), + toSyncRulesValue([1n, 'two', [new DateTimeValue('2025-08-19T00:00:00', undefined, sourceOptions)]]), CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY ) ).toStrictEqual('[1,"two",["2025-08-19 00:00:00"]]'); expect( applyValueContext( - toSyncRulesValue({ foo: { bar: new DateTimeValue('2025-08-19T00:00:00') } }), + toSyncRulesValue({ foo: { bar: new DateTimeValue('2025-08-19T00:00:00', undefined, sourceOptions) } }), CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY ) ).toStrictEqual('{"foo":{"bar":"2025-08-19 00:00:00"}}'); }); test('time value', () => { - expect( - TimeValue.parse('12:13:14')?.toSqliteValue(new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)) - ).toStrictEqual('12:13:14.000000'); - expect( - TimeValue.parse('12:13:14')?.toSqliteValue(new CompatibilityContext(CompatibilityEdition.LEGACY)) - ).toStrictEqual('12:13:14'); + expect(TimeValue.parse('12:13:14', sourceOptions)?.toSqliteValue(syncStreams)).toStrictEqual('12:13:14.000'); + expect(TimeValue.parse('12:13:14', sourceOptions)?.toSqliteValue(legacy)).toStrictEqual('12:13:14'); - expect( - TimeValue.parse('12:13:14.15')?.toSqliteValue(new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)) - ).toStrictEqual('12:13:14.150000'); - expect( - TimeValue.parse('12:13:14.15')?.toSqliteValue(new CompatibilityContext(CompatibilityEdition.LEGACY)) - ).toStrictEqual('12:13:14.15'); + expect(TimeValue.parse('12:13:14.15', sourceOptions)?.toSqliteValue(syncStreams)).toStrictEqual('12:13:14.150'); + expect(TimeValue.parse('12:13:14.15', sourceOptions)?.toSqliteValue(legacy)).toStrictEqual('12:13:14.15'); }); }); From 65979147b5c30a43b47cc9ec309171fb97c74e20 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 7 Nov 2025 11:17:25 +0100 Subject: [PATCH 2/5] Fix postgres tests --- .../module-postgres/test/src/pg_test.test.ts | 34 +++++++++++-------- .../test/src/types/registry.test.ts | 2 +- packages/jpgwire/src/pgwire_types.ts | 4 +-- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/modules/module-postgres/test/src/pg_test.test.ts b/modules/module-postgres/test/src/pg_test.test.ts index e41c61f63..c60f6910f 100644 --- a/modules/module-postgres/test/src/pg_test.test.ts +++ b/modules/module-postgres/test/src/pg_test.test.ts @@ -166,9 +166,9 @@ VALUES(10, ARRAY['null']::TEXT[]); expect(transformed[2]).toMatchObject({ id: 3n, date: '2023-03-06', - time: new TimeValue('15:47:00'), - timestamp: new DateTimeValue('2023-03-06T15:47:00.000000', '2023-03-06 15:47:00'), - timestamptz: new DateTimeValue('2023-03-06T13:47:00.000000Z', '2023-03-06 13:47:00Z') + time: TimeValue.parse('15:47:00', pgwire.postgresTimeOptions), + timestamp: new DateTimeValue('2023-03-06T15:47:00.000000', '2023-03-06 15:47:00', pgwire.postgresTimeOptions), + timestamptz: new DateTimeValue('2023-03-06T13:47:00.000000Z', '2023-03-06 13:47:00Z', pgwire.postgresTimeOptions) }); expect(transformed[3]).toMatchObject({ @@ -183,26 +183,26 @@ VALUES(10, ARRAY['null']::TEXT[]); expect(transformed[4]).toMatchObject({ id: 5n, date: '0000-01-01', - time: new TimeValue('00:00:00'), - timestamp: new DateTimeValue('0000-01-01T00:00:00'), - timestamptz: new DateTimeValue('0000-01-01T00:00:00Z') + time: TimeValue.parse('00:00:00', pgwire.postgresTimeOptions), + timestamp: new DateTimeValue('0000-01-01T00:00:00', undefined, pgwire.postgresTimeOptions), + timestamptz: new DateTimeValue('0000-01-01T00:00:00Z', undefined, pgwire.postgresTimeOptions) }); expect(transformed[5]).toMatchObject({ id: 6n, - timestamp: new DateTimeValue('1970-01-01T00:00:00.000000', '1970-01-01 00:00:00'), - timestamptz: new DateTimeValue('1970-01-01T00:00:00.000000Z', '1970-01-01 00:00:00Z') + timestamp: new DateTimeValue('1970-01-01T00:00:00.000000', '1970-01-01 00:00:00', pgwire.postgresTimeOptions), + timestamptz: new DateTimeValue('1970-01-01T00:00:00.000000Z', '1970-01-01 00:00:00Z', pgwire.postgresTimeOptions) }); expect(transformed[6]).toMatchObject({ id: 7n, - timestamp: new DateTimeValue('9999-12-31T23:59:59'), - timestamptz: new DateTimeValue('9999-12-31T23:59:59Z') + timestamp: new DateTimeValue('9999-12-31T23:59:59', undefined, pgwire.postgresTimeOptions), + timestamptz: new DateTimeValue('9999-12-31T23:59:59Z', undefined, pgwire.postgresTimeOptions) }); expect(transformed[7]).toMatchObject({ id: 8n, - timestamptz: new DateTimeValue('0022-02-03T09:13:14.000000Z', '0022-02-03 09:13:14Z') + timestamptz: new DateTimeValue('0022-02-03T09:13:14.000000Z', '0022-02-03 09:13:14Z', pgwire.postgresTimeOptions) }); expect(transformed[8]).toMatchObject({ @@ -461,7 +461,7 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12' timestamptz: '2023-03-06 13:47:00Z' }); - const newFormat = applyRowContext(row, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)); + const newFormat = applyRowContext(row, new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS })); expect(newFormat).toMatchObject({ time: '17:42:01.120000', timestamp: '2023-03-06T15:47:12.400000', @@ -535,7 +535,10 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12' mood: 'happy' }); - const newFormat = applyRowContext(transformed, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)); + const newFormat = applyRowContext( + transformed, + new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }) + ); expect(newFormat).toMatchObject({ rating: 1, composite: '{"foo":[2.0,3.0],"bar":"bar"}', @@ -601,7 +604,10 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12' ranges: '{"{[2,4),[6,8)}"}' }); - const newFormat = applyRowContext(transformed, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)); + const newFormat = applyRowContext( + transformed, + new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS }) + ); expect(newFormat).toMatchObject({ ranges: JSON.stringify([ [ diff --git a/modules/module-postgres/test/src/types/registry.test.ts b/modules/module-postgres/test/src/types/registry.test.ts index d76f78f7e..518bd1c3d 100644 --- a/modules/module-postgres/test/src/types/registry.test.ts +++ b/modules/module-postgres/test/src/types/registry.test.ts @@ -21,7 +21,7 @@ describe('custom type registry', () => { expect(applyValueContext(syncRulesValue, CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)).toStrictEqual(old); expect( - applyValueContext(syncRulesValue, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)) + applyValueContext(syncRulesValue, new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS })) ).toStrictEqual(fixed); } diff --git a/packages/jpgwire/src/pgwire_types.ts b/packages/jpgwire/src/pgwire_types.ts index bdcbb476e..68645cb56 100644 --- a/packages/jpgwire/src/pgwire_types.ts +++ b/packages/jpgwire/src/pgwire_types.ts @@ -180,6 +180,6 @@ export class PgType { } export const postgresTimeOptions: DateTimeSourceOptions = Object.freeze({ - subSecondPrecision: TimeValuePrecision.milliseconds, - defaultSubSecondPrecision: TimeValuePrecision.milliseconds + subSecondPrecision: TimeValuePrecision.microseconds, + defaultSubSecondPrecision: TimeValuePrecision.microseconds }); From 783622d3c430709d397578153d81b5b0a6a4c570 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 7 Nov 2025 11:24:39 +0100 Subject: [PATCH 3/5] Fix postgres tests --- modules/module-postgres/test/src/pg_test.test.ts | 6 +++--- packages/jpgwire/src/util.ts | 7 +------ packages/sync-rules/src/types/time.ts | 4 ++-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/modules/module-postgres/test/src/pg_test.test.ts b/modules/module-postgres/test/src/pg_test.test.ts index c60f6910f..a05ae0291 100644 --- a/modules/module-postgres/test/src/pg_test.test.ts +++ b/modules/module-postgres/test/src/pg_test.test.ts @@ -168,7 +168,7 @@ VALUES(10, ARRAY['null']::TEXT[]); date: '2023-03-06', time: TimeValue.parse('15:47:00', pgwire.postgresTimeOptions), timestamp: new DateTimeValue('2023-03-06T15:47:00.000000', '2023-03-06 15:47:00', pgwire.postgresTimeOptions), - timestamptz: new DateTimeValue('2023-03-06T13:47:00.000000Z', '2023-03-06 13:47:00Z', pgwire.postgresTimeOptions) + timestamptz: new DateTimeValue('2023-03-06T13:47:00Z', '2023-03-06 13:47:00Z', pgwire.postgresTimeOptions) }); expect(transformed[3]).toMatchObject({ @@ -191,7 +191,7 @@ VALUES(10, ARRAY['null']::TEXT[]); expect(transformed[5]).toMatchObject({ id: 6n, timestamp: new DateTimeValue('1970-01-01T00:00:00.000000', '1970-01-01 00:00:00', pgwire.postgresTimeOptions), - timestamptz: new DateTimeValue('1970-01-01T00:00:00.000000Z', '1970-01-01 00:00:00Z', pgwire.postgresTimeOptions) + timestamptz: new DateTimeValue('1970-01-01T00:00:00Z', '1970-01-01 00:00:00Z', pgwire.postgresTimeOptions) }); expect(transformed[6]).toMatchObject({ @@ -202,7 +202,7 @@ VALUES(10, ARRAY['null']::TEXT[]); expect(transformed[7]).toMatchObject({ id: 8n, - timestamptz: new DateTimeValue('0022-02-03T09:13:14.000000Z', '0022-02-03 09:13:14Z', pgwire.postgresTimeOptions) + timestamptz: new DateTimeValue('0022-02-03T09:13:14Z', '0022-02-03 09:13:14Z', pgwire.postgresTimeOptions) }); expect(transformed[8]).toMatchObject({ diff --git a/packages/jpgwire/src/util.ts b/packages/jpgwire/src/util.ts index c666a2d45..04881165f 100644 --- a/packages/jpgwire/src/util.ts +++ b/packages/jpgwire/src/util.ts @@ -262,14 +262,9 @@ export function timestamptzToSqlite(source?: string): DateTimeValue | null { const baseValue = parsed.toISOString().replace('.000', '').replace('Z', ''); - // In the new format, we always use ISO 8601. Since Postgres drops zeroes from the fractional seconds, we also pad - // that back to the highest theoretical precision (microseconds). This ensures that sorting returned values as text - // returns them in order of the time value they represent. - // // In the old format, we keep the sub-second precision only if it's not `.000`. - const missingPrecision = precision?.padEnd(7, '0') ?? '.000000'; return new DateTimeValue( - `${baseValue}${missingPrecision}Z`, + `${baseValue}${precision ?? ''}Z`, `${baseValue.replace('T', ' ')}${precision ?? ''}Z`, postgresTimeOptions ); diff --git a/packages/sync-rules/src/types/time.ts b/packages/sync-rules/src/types/time.ts index 8fbb9734b..e46fc0652 100644 --- a/packages/sync-rules/src/types/time.ts +++ b/packages/sync-rules/src/types/time.ts @@ -59,8 +59,8 @@ export class DateTimeValue extends CustomSqliteValue { } else { return [ this.iso8601Representation.slice(0, -matchSubSeconds[0].length), - matchSubSeconds[1], - matchSubSeconds[2] + matchSubSeconds[1] ?? '', + matchSubSeconds[2] ?? '' ]; } }, From 3f3da61e0128bd5f8afeeb8c718c711b0327a408 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 7 Nov 2025 11:26:58 +0100 Subject: [PATCH 4/5] Add postgres test with reduced precision --- modules/module-postgres/test/src/pg_test.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/modules/module-postgres/test/src/pg_test.test.ts b/modules/module-postgres/test/src/pg_test.test.ts index a05ae0291..dafb00723 100644 --- a/modules/module-postgres/test/src/pg_test.test.ts +++ b/modules/module-postgres/test/src/pg_test.test.ts @@ -5,7 +5,8 @@ import { SqliteInputRow, DateTimeValue, TimeValue, - CompatibilityEdition + CompatibilityEdition, + TimeValuePrecision } from '@powersync/service-sync-rules'; import { describe, expect, test } from 'vitest'; import { clearTestDb, connectPgPool, connectPgWire, TEST_URI } from './util.js'; @@ -467,6 +468,19 @@ INSERT INTO test_data(id, time, timestamp, timestamptz) VALUES (1, '17:42:01.12' timestamp: '2023-03-06T15:47:12.400000', timestamptz: '2023-03-06T13:47:00.000000Z' }); + + const reducedPrecisionFormat = applyRowContext( + row, + new CompatibilityContext({ + edition: CompatibilityEdition.SYNC_STREAMS, + maxTimeValuePrecision: TimeValuePrecision.milliseconds + }) + ); + expect(reducedPrecisionFormat).toMatchObject({ + time: '17:42:01.120', + timestamp: '2023-03-06T15:47:12.400', + timestamptz: '2023-03-06T13:47:00.000Z' + }); } finally { await db.end(); } From ba06ea114b06e1c992004281af6ec86f5edc72e7 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 7 Nov 2025 11:51:13 +0100 Subject: [PATCH 5/5] Add mysql tests --- .../test/src/mongo_test.test.ts | 17 ++++++++++++++-- .../test/src/mysql-to-sqlite.test.ts | 20 ++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/modules/module-mongodb/test/src/mongo_test.test.ts b/modules/module-mongodb/test/src/mongo_test.test.ts index 5da05b7cb..c27002535 100644 --- a/modules/module-mongodb/test/src/mongo_test.test.ts +++ b/modules/module-mongodb/test/src/mongo_test.test.ts @@ -4,7 +4,8 @@ import { CompatibilityContext, CompatibilityEdition, SqliteInputRow, - SqlSyncRules + SqlSyncRules, + TimeValuePrecision } from '@powersync/service-sync-rules'; import { describe, expect, test } from 'vitest'; @@ -555,11 +556,23 @@ bucket_definitions: noFraction: '2023-03-06 13:47:01.000Z' }); - const newFormat = applyRowContext(row, new CompatibilityContext(CompatibilityEdition.SYNC_STREAMS)); + const newFormat = applyRowContext(row, new CompatibilityContext({ edition: CompatibilityEdition.SYNC_STREAMS })); expect(newFormat).toMatchObject({ fraction: '2023-03-06T13:47:01.123Z', noFraction: '2023-03-06T13:47:01.000Z' }); + + const reducedPrecisionFormat = applyRowContext( + row, + new CompatibilityContext({ + edition: CompatibilityEdition.SYNC_STREAMS, + maxTimeValuePrecision: TimeValuePrecision.seconds + }) + ); + expect(reducedPrecisionFormat).toMatchObject({ + fraction: '2023-03-06T13:47:01Z', + noFraction: '2023-03-06T13:47:01Z' + }); } finally { await client.close(); } diff --git a/modules/module-mysql/test/src/mysql-to-sqlite.test.ts b/modules/module-mysql/test/src/mysql-to-sqlite.test.ts index 31d63342c..6a2fe013a 100644 --- a/modules/module-mysql/test/src/mysql-to-sqlite.test.ts +++ b/modules/module-mysql/test/src/mysql-to-sqlite.test.ts @@ -1,4 +1,4 @@ -import { SqliteInputRow, SqliteRow } from '@powersync/service-sync-rules'; +import { applyRowContext, CompatibilityContext, SqliteInputRow, SqliteRow } from '@powersync/service-sync-rules'; import { afterAll, describe, expect, test } from 'vitest'; import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js'; import { eventIsWriteMutation, eventIsXid } from '@module/replication/zongji/zongji-utils.js'; @@ -228,8 +228,14 @@ INSERT INTO test_data ( year_col: 2023 }; - expect(databaseRows[0]).toMatchObject(expectedResult); - expect(replicatedRows[0]).toMatchObject(expectedResult); + expect(applyRowContext(databaseRows[0], CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)).toMatchObject( + expectedResult + ); + expect(applyRowContext(replicatedRows[0], CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)).toMatchObject( + expectedResult + ); + expect(applyRowContext(databaseRows[0], new CompatibilityContext({ edition: 2 }))).toMatchObject(expectedResult); + expect(applyRowContext(replicatedRows[0], new CompatibilityContext({ edition: 2 }))).toMatchObject(expectedResult); }); test('Date types edge cases mappings', async () => { @@ -264,8 +270,12 @@ INSERT INTO test_data ( const replicatedRows = await getReplicatedRows(expectedResults.length); for (let i = 0; i < expectedResults.length; i++) { - expect(databaseRows[i]).toMatchObject(expectedResults[i]); - expect(replicatedRows[i]).toMatchObject(expectedResults[i]); + expect(applyRowContext(databaseRows[i], CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)).toMatchObject( + expectedResults[i] + ); + expect(applyRowContext(replicatedRows[i], CompatibilityContext.FULL_BACKWARDS_COMPATIBILITY)).toMatchObject( + expectedResults[i] + ); } } finally { connection.release;