@syncular/client 0.30.12 → 0.30.14
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/dist/apply.d.ts +3 -2
- package/dist/apply.js +63 -15
- package/dist/blob.d.ts +1 -0
- package/dist/blob.js +4 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +180 -159
- package/dist/failed-overlay.d.ts +6 -3
- package/dist/failed-overlay.js +50 -48
- package/dist/outbox.d.ts +4 -1
- package/dist/outbox.js +18 -12
- package/dist/outcomes.d.ts +1 -0
- package/dist/outcomes.js +12 -7
- package/dist/previous-version.js +3 -3
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +23 -14
- package/dist/state.d.ts +2 -0
- package/dist/state.js +15 -15
- package/dist/window.d.ts +2 -0
- package/dist/window.js +13 -10
- package/dist/worker-entry.js +14 -35
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +106 -37
- package/src/blob.ts +4 -3
- package/src/client.ts +224 -197
- package/src/failed-overlay.ts +96 -78
- package/src/outbox.ts +19 -16
- package/src/outcomes.ts +12 -12
- package/src/previous-version.ts +3 -3
- package/src/schema.ts +24 -18
- package/src/state.ts +15 -15
- package/src/window.ts +13 -15
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/dist/worker-entry.js
CHANGED
|
@@ -94,6 +94,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
94
94
|
autoSyncScheduled = false;
|
|
95
95
|
if (closed ||
|
|
96
96
|
client === undefined ||
|
|
97
|
+
offline ||
|
|
97
98
|
client.securityLifecycle() === 'preflight') {
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
@@ -116,6 +117,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
116
117
|
if (!autoSync ||
|
|
117
118
|
closed ||
|
|
118
119
|
client === undefined ||
|
|
120
|
+
offline ||
|
|
119
121
|
client.securityLifecycle() === 'preflight' ||
|
|
120
122
|
intent.kind === 'none') {
|
|
121
123
|
return;
|
|
@@ -156,31 +158,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
156
158
|
}
|
|
157
159
|
return client;
|
|
158
160
|
}
|
|
159
|
-
function assertOnline() {
|
|
160
|
-
if (offline) {
|
|
161
|
-
throw new ClientSyncError('sync.transport_failed', 'the worker transport is offline (setOffline(true))', true);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function gateOffline(inner) {
|
|
165
|
-
return (request) => {
|
|
166
|
-
assertOnline();
|
|
167
|
-
return inner(request);
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
/** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
|
|
171
|
-
function gateSegmentsOffline(inner) {
|
|
172
|
-
const fetchUrl = inner.fetchUrl;
|
|
173
|
-
return Object.assign(gateOffline(inner), {
|
|
174
|
-
...(fetchUrl !== undefined
|
|
175
|
-
? {
|
|
176
|
-
fetchUrl: (url, onProgress) => {
|
|
177
|
-
assertOnline();
|
|
178
|
-
return fetchUrl(url, onProgress);
|
|
179
|
-
},
|
|
180
|
-
}
|
|
181
|
-
: {}),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
161
|
async function init(config) {
|
|
185
162
|
if (client !== undefined) {
|
|
186
163
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
@@ -215,9 +192,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
215
192
|
}
|
|
216
193
|
}
|
|
217
194
|
}
|
|
218
|
-
const transport =
|
|
195
|
+
const transport = overrides.createTransport !== undefined
|
|
219
196
|
? overrides.createTransport(config)
|
|
220
|
-
: httpSyncTransport(config.endpoints.syncUrl, http)
|
|
197
|
+
: httpSyncTransport(config.endpoints.syncUrl, http);
|
|
221
198
|
const segments = overrides.createSegments !== undefined
|
|
222
199
|
? overrides.createSegments(config)
|
|
223
200
|
: config.endpoints.segmentsUrl !== undefined
|
|
@@ -231,18 +208,15 @@ export function startSyncWorker(overrides = {}) {
|
|
|
231
208
|
// `{clientId}` URL placeholder — start the client first, then attach
|
|
232
209
|
// the connector through a mutable slot read at connect time.
|
|
233
210
|
let realtimeConnector;
|
|
211
|
+
offline = config.transportEnabled === false;
|
|
234
212
|
const started = new SyncClient({
|
|
213
|
+
transportEnabled: !offline,
|
|
235
214
|
database,
|
|
236
215
|
schema: config.schema,
|
|
237
216
|
transport,
|
|
238
|
-
...(segments !== undefined
|
|
239
|
-
? { segments: gateSegmentsOffline(segments) }
|
|
240
|
-
: {}),
|
|
217
|
+
...(segments !== undefined ? { segments } : {}),
|
|
241
218
|
...(blobs !== undefined ? { blobs } : {}),
|
|
242
219
|
realtime: (handlers) => {
|
|
243
|
-
if (offline) {
|
|
244
|
-
throw new ClientSyncError('sync.transport_failed', 'the worker realtime channel is offline (setOffline(true))', true);
|
|
245
|
-
}
|
|
246
220
|
if (realtimeConnector === undefined) {
|
|
247
221
|
throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
|
|
248
222
|
}
|
|
@@ -399,8 +373,13 @@ export function startSyncWorker(overrides = {}) {
|
|
|
399
373
|
},
|
|
400
374
|
setOffline: (value) => {
|
|
401
375
|
offline = value;
|
|
402
|
-
|
|
403
|
-
|
|
376
|
+
requireClient().setTransportEnabled(!value);
|
|
377
|
+
if (offline) {
|
|
378
|
+
if (backgroundTimer !== undefined)
|
|
379
|
+
clearTimeout(backgroundTimer);
|
|
380
|
+
backgroundTimer = undefined;
|
|
381
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
382
|
+
}
|
|
404
383
|
else if (client !== undefined) {
|
|
405
384
|
const snapshot = client.diagnosticsSnapshot();
|
|
406
385
|
post({
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export interface SyncClientHandleConfig {
|
|
|
85
85
|
readonly encryption?: EncryptionKeyringConfig;
|
|
86
86
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
87
87
|
readonly securityPreflight?: boolean;
|
|
88
|
+
readonly transportEnabled?: boolean;
|
|
88
89
|
readonly clientId?: string;
|
|
89
90
|
readonly limits?: SyncClientLimits;
|
|
90
91
|
readonly retainFailedCommits?: boolean;
|
package/dist/worker-host.js
CHANGED
|
@@ -566,6 +566,9 @@ function buildInitConfig(config, headers = config.headers) {
|
|
|
566
566
|
...(config.encryption !== undefined
|
|
567
567
|
? { encryption: config.encryption }
|
|
568
568
|
: {}),
|
|
569
|
+
...(config.transportEnabled !== undefined
|
|
570
|
+
? { transportEnabled: config.transportEnabled }
|
|
571
|
+
: {}),
|
|
569
572
|
...(config.securityPreflight !== undefined
|
|
570
573
|
? { securityPreflight: config.securityPreflight }
|
|
571
574
|
: {}),
|
|
@@ -78,6 +78,7 @@ export interface WorkerInitConfig {
|
|
|
78
78
|
readonly encryption?: EncryptionKeyringConfig;
|
|
79
79
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
80
80
|
readonly securityPreflight?: boolean;
|
|
81
|
+
readonly transportEnabled?: boolean;
|
|
81
82
|
readonly clientId?: string;
|
|
82
83
|
readonly limits?: SyncClientLimits;
|
|
83
84
|
readonly retainFailedCommits?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.14",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.14"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.14",
|
|
96
96
|
"playwright": "1.63.0",
|
|
97
97
|
"@electric-sql/pglite": "^0.5.4"
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hasUniqueIndex } from './schema';
|
|
2
|
+
import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox';
|
|
3
|
+
import { RETAINED_ROWS, type OverlayScope } from './failed-overlay';
|
|
2
4
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
|
|
3
5
|
/**
|
|
4
6
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
} from './schema';
|
|
29
31
|
|
|
30
32
|
/** Lets the client wrap the physical write in its revision transaction. */
|
|
31
|
-
export type ApplyTransaction =
|
|
33
|
+
export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
|
|
32
34
|
|
|
33
35
|
function upsertSql(table: CompiledClientTable): string {
|
|
34
36
|
const names = [
|
|
@@ -96,8 +98,7 @@ export async function applyCommitFrame(
|
|
|
96
98
|
schema: CompiledClientSchema,
|
|
97
99
|
frame: CommitFrame,
|
|
98
100
|
encryption?: EncryptionConfig,
|
|
99
|
-
transaction: (fn: () =>
|
|
100
|
-
db.transaction(fn),
|
|
101
|
+
transaction: (fn: () => OverlayScope) => unknown = (fn) => db.transaction(fn),
|
|
101
102
|
): Promise<void> {
|
|
102
103
|
type Resolved =
|
|
103
104
|
| { op: 'delete'; table: CompiledClientTable; rowId: string }
|
|
@@ -142,18 +143,18 @@ export async function applyCommitFrame(
|
|
|
142
143
|
});
|
|
143
144
|
}
|
|
144
145
|
transaction(() => {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
restoreFailedBases(db, schema,
|
|
146
|
+
const scope = resolved.map((change, index) =>
|
|
147
|
+
JSON.stringify(
|
|
148
|
+
hasUniqueIndex(change.table)
|
|
149
|
+
? [change.table.name]
|
|
150
|
+
: [change.table.name, frame.changes[index]!.rowId],
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
restoreFailedBases(db, schema, scope);
|
|
153
154
|
for (const [index, change] of resolved.entries()) {
|
|
154
155
|
const rowId = frame.changes[index]!.rowId;
|
|
155
156
|
db.exec(
|
|
156
|
-
|
|
157
|
+
`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`,
|
|
157
158
|
[frame.commitSeq, change.table.name, rowId],
|
|
158
159
|
);
|
|
159
160
|
db.exec(
|
|
@@ -166,7 +167,7 @@ export async function applyCommitFrame(
|
|
|
166
167
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
|
-
return
|
|
170
|
+
return scope;
|
|
170
171
|
});
|
|
171
172
|
}
|
|
172
173
|
|
|
@@ -238,6 +239,41 @@ export function localScopePredicate(
|
|
|
238
239
|
return { sql, params };
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
function scopedRows(
|
|
243
|
+
db: ClientDatabase,
|
|
244
|
+
table: CompiledClientTable,
|
|
245
|
+
scope: ReturnType<typeof localScopePredicate>,
|
|
246
|
+
) {
|
|
247
|
+
return scope
|
|
248
|
+
? db.query(
|
|
249
|
+
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`,
|
|
250
|
+
scope.params,
|
|
251
|
+
)
|
|
252
|
+
: [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function bootstrapScope(
|
|
256
|
+
db: ClientDatabase,
|
|
257
|
+
table: CompiledClientTable,
|
|
258
|
+
effective: ScopeMap,
|
|
259
|
+
): string[] {
|
|
260
|
+
const rows = scopedRows(db, table, localScopePredicate(table, effective));
|
|
261
|
+
rows.push(
|
|
262
|
+
...db
|
|
263
|
+
.query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
|
|
264
|
+
table.name,
|
|
265
|
+
])
|
|
266
|
+
.filter((row) =>
|
|
267
|
+
[row.base, row.intent].some(
|
|
268
|
+
(json) =>
|
|
269
|
+
json !== null &&
|
|
270
|
+
matchesLocalScopes(table, effective, JSON.parse(json as string)),
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
export function deleteScopedRows(
|
|
242
278
|
db: ClientDatabase,
|
|
243
279
|
table: CompiledClientTable,
|
|
@@ -246,10 +282,7 @@ export function deleteScopedRows(
|
|
|
246
282
|
const scope = localScopePredicate(table, effective);
|
|
247
283
|
if (!scope) return;
|
|
248
284
|
const { sql, params } = scope;
|
|
249
|
-
for (const row of db
|
|
250
|
-
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
|
|
251
|
-
params,
|
|
252
|
-
)) {
|
|
285
|
+
for (const row of scopedRows(db, table, scope)) {
|
|
253
286
|
retainedBaseWrite(db, table, String(row.id));
|
|
254
287
|
}
|
|
255
288
|
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
@@ -451,8 +484,31 @@ export async function applySqliteSegment(
|
|
|
451
484
|
predicates.push(`${primaryKey} <= ?`);
|
|
452
485
|
params.push(boundary);
|
|
453
486
|
}
|
|
487
|
+
const replay =
|
|
488
|
+
countOutbox(db) ||
|
|
489
|
+
db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
|
|
490
|
+
table.name,
|
|
491
|
+
]).length;
|
|
492
|
+
const rows = replay
|
|
493
|
+
? db.query(
|
|
494
|
+
`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
|
|
495
|
+
params,
|
|
496
|
+
)
|
|
497
|
+
: [];
|
|
454
498
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
455
|
-
|
|
499
|
+
const scope = !replay
|
|
500
|
+
? []
|
|
501
|
+
: hasUniqueIndex(table)
|
|
502
|
+
? [JSON.stringify([table.name])]
|
|
503
|
+
: [
|
|
504
|
+
...(after === undefined && options.clearFirst
|
|
505
|
+
? bootstrapScope(db, table, options.effective)
|
|
506
|
+
: []),
|
|
507
|
+
...rows.map((row) =>
|
|
508
|
+
JSON.stringify([table.name, String(row[table.primaryKey])]),
|
|
509
|
+
),
|
|
510
|
+
];
|
|
511
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
456
512
|
if (after === undefined && options.clearFirst)
|
|
457
513
|
deleteScopedRows(db, table, options.effective);
|
|
458
514
|
db.exec(
|
|
@@ -460,23 +516,22 @@ export async function applySqliteSegment(
|
|
|
460
516
|
params,
|
|
461
517
|
);
|
|
462
518
|
applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
519
|
+
if (protectedRows)
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
retainedBaseWrite(
|
|
522
|
+
db,
|
|
523
|
+
table,
|
|
524
|
+
String(row[table.primaryKey]),
|
|
525
|
+
Object.fromEntries(
|
|
526
|
+
table.columns.map((column) => [
|
|
527
|
+
column.name,
|
|
528
|
+
rowValueToJson(row[column.name] as RowValue),
|
|
529
|
+
]),
|
|
530
|
+
),
|
|
531
|
+
Number(row._syncular_version),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return scope;
|
|
480
535
|
});
|
|
481
536
|
options.onProgress?.(applied);
|
|
482
537
|
await yieldToHost();
|
|
@@ -537,7 +592,20 @@ export async function applyRowsSegment(
|
|
|
537
592
|
first = false;
|
|
538
593
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
539
594
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
540
|
-
const
|
|
595
|
+
const scope: OverlayScope = hasUniqueIndex(table)
|
|
596
|
+
? [JSON.stringify([table.name])]
|
|
597
|
+
: [
|
|
598
|
+
...(clearThisBlock
|
|
599
|
+
? bootstrapScope(db, table, options.effective)
|
|
600
|
+
: []),
|
|
601
|
+
...rows.map((row) =>
|
|
602
|
+
JSON.stringify([
|
|
603
|
+
table.name,
|
|
604
|
+
String(row.values[table.primaryKeyIndex]),
|
|
605
|
+
]),
|
|
606
|
+
),
|
|
607
|
+
];
|
|
608
|
+
const protectedRows = restoreFailedBases(db, schema, scope);
|
|
541
609
|
if (clearThisBlock) {
|
|
542
610
|
deleteScopedRows(db, table, options.effective);
|
|
543
611
|
}
|
|
@@ -546,6 +614,7 @@ export async function applyRowsSegment(
|
|
|
546
614
|
applied += 1;
|
|
547
615
|
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
548
616
|
}
|
|
617
|
+
return scope;
|
|
549
618
|
});
|
|
550
619
|
options.onProgress?.(applied);
|
|
551
620
|
await yieldToHost();
|
package/src/blob.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
|
|
1
2
|
/**
|
|
2
3
|
* Client-side blob cache + transport (SPEC.md §5.9.7).
|
|
3
4
|
*
|
|
@@ -187,7 +188,7 @@ export function enforceBlobCacheCap(
|
|
|
187
188
|
if (total <= maxBytes) return [];
|
|
188
189
|
// Oldest eligible body first, with blob ID as a stable tie.
|
|
189
190
|
const candidates = db.query(
|
|
190
|
-
`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM
|
|
191
|
+
`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`,
|
|
191
192
|
);
|
|
192
193
|
const evicted: string[] = [];
|
|
193
194
|
db.transaction(() => {
|
|
@@ -207,7 +208,7 @@ export function listPendingUploads(
|
|
|
207
208
|
): { blobId: string; mediaType?: string }[] {
|
|
208
209
|
return db
|
|
209
210
|
.query(
|
|
210
|
-
|
|
211
|
+
`SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM ${BLOB_COMMIT_REFS} r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id`,
|
|
211
212
|
)
|
|
212
213
|
.map((row) => {
|
|
213
214
|
if (
|
|
@@ -285,7 +286,7 @@ export function deleteUnreferencedCachedBlobs(
|
|
|
285
286
|
schema: CompiledClientSchema,
|
|
286
287
|
): void {
|
|
287
288
|
db.exec(
|
|
288
|
-
`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM
|
|
289
|
+
`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`,
|
|
289
290
|
);
|
|
290
291
|
}
|
|
291
292
|
|