@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.
Files changed (44) hide show
  1. package/README.md +66 -9
  2. package/dist/admin.js +1 -5
  3. package/dist/authoritative-query.d.ts +14 -6
  4. package/dist/authoritative-query.js +60 -82
  5. package/dist/d1-storage.d.ts +13 -1
  6. package/dist/d1-storage.js +468 -121
  7. package/dist/operations.d.ts +2 -0
  8. package/dist/operations.js +23 -4
  9. package/dist/postgres-storage.d.ts +6 -1
  10. package/dist/postgres-storage.js +80 -24
  11. package/dist/prune.d.ts +3 -1
  12. package/dist/prune.js +18 -14
  13. package/dist/pull.js +79 -39
  14. package/dist/push.js +5 -5
  15. package/dist/realtime.d.ts +1 -1
  16. package/dist/realtime.js +16 -10
  17. package/dist/relational-rows.d.ts +7 -1
  18. package/dist/relational-rows.js +17 -2
  19. package/dist/sqlite-bun.js +2 -2
  20. package/dist/sqlite-image.d.ts +3 -3
  21. package/dist/sqlite-image.js +10 -6
  22. package/dist/sqlite-node.js +2 -2
  23. package/dist/sqlite-storage.d.ts +6 -1
  24. package/dist/sqlite-storage.js +93 -32
  25. package/dist/storage-errors.d.ts +1 -1
  26. package/dist/storage-errors.js +7 -0
  27. package/dist/storage.d.ts +29 -5
  28. package/package.json +2 -2
  29. package/src/admin.ts +4 -6
  30. package/src/authoritative-query.ts +89 -94
  31. package/src/d1-storage.ts +641 -159
  32. package/src/operations.ts +31 -3
  33. package/src/postgres-storage.ts +146 -46
  34. package/src/prune.ts +29 -15
  35. package/src/pull.ts +102 -49
  36. package/src/push.ts +5 -5
  37. package/src/realtime.ts +20 -9
  38. package/src/relational-rows.ts +18 -2
  39. package/src/sqlite-bun.ts +2 -2
  40. package/src/sqlite-image.ts +26 -15
  41. package/src/sqlite-node.ts +2 -2
  42. package/src/sqlite-storage.ts +131 -37
  43. package/src/storage-errors.ts +22 -1
  44. 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
- const RESERVED_ALIAS = new Set([
18
- 'on',
19
- 'where',
20
- 'group',
21
- 'order',
22
- 'inner',
23
- 'left',
24
- 'right',
25
- 'full',
26
- 'outer',
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
- function maskedSql(sql: string): string {
68
- let out = '';
69
- let index = 0;
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
- tables: ReadonlyMap<string, CompiledTable>,
89
- ): PreparedAuthoritativeQuery {
90
- if (maskedSql(sql).includes(';'))
91
- throw new Error('registered query must be one SELECT');
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
- for (const match of masked.matchAll(TABLE_REF_RE)) {
101
- const rawTable = match[3] as string;
102
- const table = tables.get(rawTable);
103
- if (table === undefined) continue;
104
- if (!declared.has(table.name)) {
105
- throw new Error('registered query table metadata does not match its SQL');
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
- if (!table.materialize) {
108
- throw new Error('registered query targets a non-materialized table');
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
- let alias = match[4];
111
- if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
112
- alias = undefined;
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
- const matchStart = match.index ?? 0;
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
- let rewritten = sql;
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
- for (let index = 0; index < rewritten.length; index += 1) {
148
- if (rewritten.startsWith('/*syncular_partition*/?', index)) {
149
- rendered += '?';
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 += '/*syncular_partition*/?'.length - 1;
145
+ index = replacement.end - 1;
146
+ nextRelation += 1;
152
147
  continue;
153
148
  }
154
- const protectedEnd = protectedSqlEnd(rewritten, index);
149
+ const protectedEnd = protectedSqlEnd(sql, index);
155
150
  if (protectedEnd !== undefined) {
156
- rendered += rewritten.slice(index, protectedEnd);
151
+ rendered += sql.slice(index, protectedEnd);
157
152
  index = protectedEnd - 1;
158
153
  continue;
159
154
  }
160
- const char = rewritten[index] as string;
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 < rewritten.length && /[0-9]/.test(rewritten[end] as string)) {
161
+ while (end < sql.length && /[0-9]/.test(sql[end] as string)) {
167
162
  end += 1;
168
163
  }
169
- const numbered = rewritten.slice(index + 1, end);
164
+ const numbered = sql.slice(index + 1, end);
170
165
  const parameterIndex =
171
166
  numbered.length > 0
172
167
  ? Number.parseInt(numbered, 10) - 1