Skip to content

Commit 374e087

Browse files
author
Sravan Sridhar
committed
fix(sync-rules): upper()/lower() should be ASCII-only to match SQLite
Previously the server used String.prototype.toUpperCase()/.toLowerCase(), which are Unicode-aware and perform length-changing case folds (ß -> SS, fi -> FI). SQLite's default is ASCII-only, so server-side bucket keys silently disagreed with client-side parameter values for any non-ASCII letter. Same silent-failure class as merged powersync-ja#644 / powersync-ja#645 / powersync-ja#646 / powersync-ja#647 and open PR powersync-ja#662 (powersync-ja#565 JOIN loud-error).
1 parent 02d4ae6 commit 374e087

3 files changed

Lines changed: 77 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/service-sync-rules': patch
3+
---
4+
5+
Make `upper()` and `lower()` ASCII-only in the JS evaluator to match SQLite's default behaviour. Previously the server used `String.prototype.toUpperCase()` / `.toLowerCase()`, which are Unicode-aware and perform length-changing case folds (ß -> SS, fi -> FI, İ -> İ̇). The client SQLite uses ASCII-only semantics for the same calls, so server-side bucket keys silently disagreed with client-side parameter values for any source data containing non-ASCII letters.

packages/sync-rules/src/sql_functions.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,30 +60,62 @@ export function getOperatorFunction(op: string): SqlFunction {
6060
};
6161
}
6262

63+
/**
64+
* SQLite's default `upper()` and `lower()` are ASCII-only: only `a-z` <-> `A-Z`
65+
* are converted; all other characters (including length-changing case folds
66+
* like ß -> SS, fi -> FI, I-dot) pass through unchanged.
67+
*
68+
* `String.prototype.toUpperCase()` / `.toLowerCase()` in JavaScript are
69+
* Unicode-aware and DO perform length-changing folds, so an `upper()` or
70+
* `lower()` call evaluated server-side here produces a different string than
71+
* the same call run client-side against SQLite. Bucket keys silently desync;
72+
* rows containing non-ASCII letters end up routed to the wrong bucket.
73+
*
74+
* Same class of bug as the merged Sync Streams correctness fixes
75+
* #644 / #645 / #646 / #647.
76+
*/
77+
function asciiToUpper(text: string): string {
78+
let out = '';
79+
for (let i = 0; i < text.length; i++) {
80+
const code = text.charCodeAt(i);
81+
out += code >= 97 && code <= 122 ? String.fromCharCode(code - 32) : text[i];
82+
}
83+
return out;
84+
}
85+
86+
function asciiToLower(text: string): string {
87+
let out = '';
88+
for (let i = 0; i < text.length; i++) {
89+
const code = text.charCodeAt(i);
90+
out += code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : text[i];
91+
}
92+
return out;
93+
}
94+
6395
const upper: DocumentedSqlFunction = {
6496
debugName: 'upper',
6597
call(value: SqliteValue) {
6698
const text = castAsText(value);
67-
return text?.toUpperCase() ?? null;
99+
return text == null ? null : asciiToUpper(text);
68100
},
69101
parameters: [{ name: 'value', type: ExpressionType.ANY, optional: false }],
70102
getReturnType(args) {
71103
return ExpressionType.TEXT;
72104
},
73-
detail: 'Convert text to upper case'
105+
detail: 'Convert ASCII a-z to A-Z (matches SQLite default; non-ASCII passes through)'
74106
};
75107

76108
const lower: DocumentedSqlFunction = {
77109
debugName: 'lower',
78110
call(value: SqliteValue) {
79111
const text = castAsText(value);
80-
return text?.toLowerCase() ?? null;
112+
return text == null ? null : asciiToLower(text);
81113
},
82114
parameters: [{ name: 'value', type: ExpressionType.ANY, optional: false }],
83115
getReturnType(args) {
84116
return ExpressionType.TEXT;
85117
},
86-
detail: 'Convert text to lower case'
118+
detail: 'Convert ASCII A-Z to a-z (matches SQLite default; non-ASCII passes through)'
87119
};
88120

89121
const substring: DocumentedSqlFunction = {

packages/sync-rules/test/src/sync_plan/evaluator/sqlite_semantics.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,42 @@ import { requestParameters, TestSourceTable } from '../../util.js';
44
import { syncTest } from './utils.js';
55

66
describe('operators match SQLite', () => {
7+
syncTest('upper / lower use ASCII-only semantics (matches SQLite default)', ({ sync }) => {
8+
// SQLite's default upper()/lower() only handles a-z / A-Z; non-ASCII
9+
// letters pass through unchanged. JavaScript's toUpperCase/toLowerCase
10+
// are Unicode-aware and length-changing (ß -> SS, fi -> FI). When the
11+
// evaluator and the client disagree on the result of upper(), bucket
12+
// keys silently diverge and rows are routed to the wrong bucket.
13+
const streams = sync.prepareSyncStreams(`
14+
config:
15+
edition: 3
16+
17+
streams:
18+
a:
19+
query: 'SELECT id, UPPER(name) AS upper, LOWER(name) AS lower FROM tbl'
20+
`);
21+
22+
const table = new TestSourceTable('tbl');
23+
24+
function evaluate(name: SqliteValue) {
25+
const [row] = streams.evaluateRow({ sourceTable: table, record: { id: 'ignored', name } });
26+
return { upper: row.data['upper'], lower: row.data['lower'] };
27+
}
28+
29+
// ASCII works exactly as before.
30+
expect(evaluate('hello')).toStrictEqual({ upper: 'HELLO', lower: 'hello' });
31+
expect(evaluate('Hello World')).toStrictEqual({ upper: 'HELLO WORLD', lower: 'hello world' });
32+
33+
// Non-ASCII letters now pass through unchanged (matching SQLite),
34+
// instead of being length-changed by JS Unicode folding.
35+
expect(evaluate('straße')).toStrictEqual({ upper: 'STRAßE', lower: 'straße' });
36+
expect(evaluate('file')).toStrictEqual({ upper: 'fiLE', lower: 'file' });
37+
38+
// Length is preserved (was previously length-changed by JS folds).
39+
expect(evaluate('straße').upper).toHaveLength('straße'.length);
40+
expect(evaluate('file').upper).toHaveLength('file'.length);
41+
});
42+
743
syncTest('division by zero', ({ sync }) => {
844
// Regression test for https://github.com/powersync-ja/powersync-service/pull/646.
945
const streams = sync.prepareSyncStreams(`

0 commit comments

Comments
 (0)