@syncular/server 0.15.48 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -9
- package/dist/admin.js +1 -5
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/d1-storage.d.ts +13 -1
- package/dist/d1-storage.js +468 -121
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +23 -4
- package/dist/postgres-storage.d.ts +6 -1
- package/dist/postgres-storage.js +80 -24
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.js +79 -39
- package/dist/push.js +5 -5
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +16 -10
- package/dist/relational-rows.d.ts +7 -1
- package/dist/relational-rows.js +17 -2
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-storage.d.ts +6 -1
- package/dist/sqlite-storage.js +93 -32
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +7 -0
- package/dist/storage.d.ts +29 -5
- package/package.json +2 -2
- package/src/admin.ts +4 -6
- package/src/authoritative-query.ts +89 -94
- package/src/d1-storage.ts +641 -159
- package/src/operations.ts +31 -3
- package/src/postgres-storage.ts +146 -46
- package/src/prune.ts +29 -15
- package/src/pull.ts +102 -49
- package/src/push.ts +5 -5
- package/src/realtime.ts +20 -9
- package/src/relational-rows.ts +18 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-storage.ts +131 -37
- package/src/storage-errors.ts +22 -1
- package/src/storage.ts +50 -5
|
@@ -14,28 +14,16 @@ export interface BoundAuthoritativeQuery {
|
|
|
14
14
|
|
|
15
15
|
const PARTITION_BIND = Symbol('syncular.authoritative_partition');
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'natural',
|
|
28
|
-
'join',
|
|
29
|
-
'cross',
|
|
30
|
-
'using',
|
|
31
|
-
'limit',
|
|
32
|
-
'having',
|
|
33
|
-
]);
|
|
34
|
-
const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
|
|
35
|
-
const TABLE_REF_RE = new RegExp(
|
|
36
|
-
`\\b(FROM|(?:NATURAL\\s+)?(?:(?:LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${[...RESERVED_ALIAS].join('|')})\\b)${IDENT}))?`,
|
|
37
|
-
'gi',
|
|
38
|
-
);
|
|
17
|
+
/** Compiler-proven occurrences in the exact generated positional statement. */
|
|
18
|
+
export interface AuthoritativeRelationPlan {
|
|
19
|
+
readonly sql: string;
|
|
20
|
+
readonly relations: readonly {
|
|
21
|
+
readonly table: string;
|
|
22
|
+
readonly start: number;
|
|
23
|
+
readonly end: number;
|
|
24
|
+
readonly alias?: string;
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
39
27
|
|
|
40
28
|
function protectedSqlEnd(sql: string, index: number): number | undefined {
|
|
41
29
|
const char = sql[index];
|
|
@@ -64,66 +52,57 @@ function protectedSqlEnd(sql: string, index: number): number | undefined {
|
|
|
64
52
|
return undefined;
|
|
65
53
|
}
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
while (index < sql.length) {
|
|
71
|
-
const end = protectedSqlEnd(sql, index);
|
|
72
|
-
if (end === undefined) out += sql[index];
|
|
73
|
-
else out += sql.slice(index, end).replace(/[^\n]/g, ' ');
|
|
74
|
-
index = end ?? index + 1;
|
|
75
|
-
}
|
|
76
|
-
return out;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Turn generated local SQL into a partition-local authoritative statement.
|
|
81
|
-
* Only relations declared by the generated descriptor are rewritten. Values
|
|
82
|
-
* remain parameters; request data is never interpolated into SQL.
|
|
83
|
-
*/
|
|
84
|
-
export function prepareAuthoritativeQuery(
|
|
85
|
-
sql: string,
|
|
86
|
-
params: readonly AuthoritativeQueryValue[],
|
|
55
|
+
/** Validate trusted generated metadata before registration or storage execution. */
|
|
56
|
+
export function validateAuthoritativeRelationPlan(
|
|
57
|
+
plan: AuthoritativeRelationPlan,
|
|
87
58
|
declaredTables: readonly string[],
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
59
|
+
): void {
|
|
60
|
+
if (
|
|
61
|
+
plan === undefined ||
|
|
62
|
+
typeof plan.sql !== 'string' ||
|
|
63
|
+
!Array.isArray(plan.relations)
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'registered query requires generated relation plans; regenerate queries',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const sql = plan.sql;
|
|
92
70
|
const declared = new Set(declaredTables);
|
|
93
|
-
const masked = maskedSql(sql);
|
|
94
|
-
const replacements: Array<{
|
|
95
|
-
readonly start: number;
|
|
96
|
-
readonly end: number;
|
|
97
|
-
readonly text: string;
|
|
98
|
-
}> = [];
|
|
99
71
|
const found = new Set<string>();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
72
|
+
let previousEnd = 0;
|
|
73
|
+
for (const relation of plan.relations) {
|
|
74
|
+
if (
|
|
75
|
+
!Number.isSafeInteger(relation.start) ||
|
|
76
|
+
!Number.isSafeInteger(relation.end) ||
|
|
77
|
+
relation.start < previousEnd ||
|
|
78
|
+
relation.end <= relation.start ||
|
|
79
|
+
relation.end > sql.length
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'registered query relation boundaries do not match its SQL; regenerate queries',
|
|
83
|
+
);
|
|
106
84
|
}
|
|
107
|
-
|
|
108
|
-
|
|
85
|
+
previousEnd = relation.end;
|
|
86
|
+
if (!declared.has(relation.table)) {
|
|
87
|
+
throw new Error('registered query table metadata does not match its SQL');
|
|
109
88
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
89
|
+
const spelling = sql.slice(relation.start, relation.end);
|
|
90
|
+
const quote = spelling[0];
|
|
91
|
+
const name =
|
|
92
|
+
quote === '['
|
|
93
|
+
? spelling.slice(1, -1)
|
|
94
|
+
: quote === '"' || quote === '`'
|
|
95
|
+
? spelling
|
|
96
|
+
.slice(1, -1)
|
|
97
|
+
.split(quote + quote)
|
|
98
|
+
.join(quote)
|
|
99
|
+
: spelling;
|
|
100
|
+
if (name.toLowerCase() !== relation.table.toLowerCase()) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'registered query relation name does not match its SQL; regenerate queries',
|
|
103
|
+
);
|
|
113
104
|
}
|
|
114
|
-
|
|
115
|
-
const afterOperator = (match[1] as string).length;
|
|
116
|
-
const relative = match[0]
|
|
117
|
-
.toLowerCase()
|
|
118
|
-
.indexOf(rawTable.toLowerCase(), afterOperator);
|
|
119
|
-
const start = matchStart + relative;
|
|
120
|
-
const projection = table.columns.map((column) => quoteIdent(column.name));
|
|
121
|
-
replacements.push({
|
|
122
|
-
start,
|
|
123
|
-
end: start + rawTable.length,
|
|
124
|
-
text: `(SELECT ${projection.join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=/*syncular_partition*/?)${alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
|
|
125
|
-
});
|
|
126
|
-
found.add(table.name);
|
|
105
|
+
found.add(relation.table);
|
|
127
106
|
}
|
|
128
107
|
if (
|
|
129
108
|
found.size !== declared.size ||
|
|
@@ -131,42 +110,58 @@ export function prepareAuthoritativeQuery(
|
|
|
131
110
|
) {
|
|
132
111
|
throw new Error('registered query table metadata does not match its SQL');
|
|
133
112
|
}
|
|
134
|
-
|
|
135
|
-
for (const replacement of replacements.sort(
|
|
136
|
-
(left, right) => right.start - left.start,
|
|
137
|
-
)) {
|
|
138
|
-
rewritten =
|
|
139
|
-
rewritten.slice(0, replacement.start) +
|
|
140
|
-
replacement.text +
|
|
141
|
-
rewritten.slice(replacement.end);
|
|
142
|
-
}
|
|
113
|
+
}
|
|
143
114
|
|
|
115
|
+
/** Bind every compiler-proven table occurrence to the authenticated partition. */
|
|
116
|
+
export function prepareAuthoritativeQuery(
|
|
117
|
+
plan: AuthoritativeRelationPlan,
|
|
118
|
+
params: readonly AuthoritativeQueryValue[],
|
|
119
|
+
declaredTables: readonly string[],
|
|
120
|
+
tables: ReadonlyMap<string, CompiledTable>,
|
|
121
|
+
): PreparedAuthoritativeQuery {
|
|
122
|
+
validateAuthoritativeRelationPlan(plan, declaredTables);
|
|
123
|
+
const sql = plan.sql;
|
|
124
|
+
const replacements = plan.relations.map((relation) => {
|
|
125
|
+
const table = tables.get(relation.table);
|
|
126
|
+
if (table === undefined)
|
|
127
|
+
throw new Error('registered query targets an unknown table');
|
|
128
|
+
if (!table.materialize)
|
|
129
|
+
throw new Error('registered query targets a non-materialized table');
|
|
130
|
+
return {
|
|
131
|
+
start: relation.start,
|
|
132
|
+
end: relation.end,
|
|
133
|
+
text: `(SELECT ${table.columns.map((column) => quoteIdent(column.name)).join(', ')} FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=?)${relation.alias === undefined ? ` AS ${quoteIdent(table.name)}` : ''}`,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
144
136
|
const bound: (AuthoritativeQueryValue | typeof PARTITION_BIND)[] = [];
|
|
145
137
|
let anonymousIndex = 0;
|
|
146
138
|
let rendered = '';
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
139
|
+
let nextRelation = 0;
|
|
140
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
141
|
+
const replacement = replacements[nextRelation];
|
|
142
|
+
if (replacement?.start === index) {
|
|
143
|
+
rendered += replacement.text;
|
|
150
144
|
bound.push(PARTITION_BIND);
|
|
151
|
-
index
|
|
145
|
+
index = replacement.end - 1;
|
|
146
|
+
nextRelation += 1;
|
|
152
147
|
continue;
|
|
153
148
|
}
|
|
154
|
-
const protectedEnd = protectedSqlEnd(
|
|
149
|
+
const protectedEnd = protectedSqlEnd(sql, index);
|
|
155
150
|
if (protectedEnd !== undefined) {
|
|
156
|
-
rendered +=
|
|
151
|
+
rendered += sql.slice(index, protectedEnd);
|
|
157
152
|
index = protectedEnd - 1;
|
|
158
153
|
continue;
|
|
159
154
|
}
|
|
160
|
-
const char =
|
|
155
|
+
const char = sql[index] as string;
|
|
161
156
|
if (char !== '?') {
|
|
162
157
|
rendered += char;
|
|
163
158
|
continue;
|
|
164
159
|
}
|
|
165
160
|
let end = index + 1;
|
|
166
|
-
while (end <
|
|
161
|
+
while (end < sql.length && /[0-9]/.test(sql[end] as string)) {
|
|
167
162
|
end += 1;
|
|
168
163
|
}
|
|
169
|
-
const numbered =
|
|
164
|
+
const numbered = sql.slice(index + 1, end);
|
|
170
165
|
const parameterIndex =
|
|
171
166
|
numbered.length > 0
|
|
172
167
|
? Number.parseInt(numbered, 10) - 1
|