@starkeep/storage-sqlite 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/index.cjs +702 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +676 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
SqliteAppSyncableApplier: () => SqliteAppSyncableApplier,
|
|
24
|
+
SqliteAppSyncableNamespaceStore: () => SqliteAppSyncableNamespaceStore,
|
|
25
|
+
SqliteDatabaseAdapter: () => SqliteDatabaseAdapter,
|
|
26
|
+
appSyncableTableName: () => appSyncableTableName,
|
|
27
|
+
createSqliteAccessPolicyStore: () => createSqliteAccessPolicyStore,
|
|
28
|
+
deleteAppSyncableNamespace: () => deleteAppSyncableNamespace,
|
|
29
|
+
getAppSyncableNamespace: () => getAppSyncableNamespace,
|
|
30
|
+
initializeLocalSchema: () => initializeLocalSchema,
|
|
31
|
+
listAppSyncableNamespaces: () => listAppSyncableNamespaces,
|
|
32
|
+
upsertAppSyncableNamespace: () => upsertAppSyncableNamespace
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/adapter.ts
|
|
37
|
+
var import_node_sqlite = require("sqlite");
|
|
38
|
+
var import_node_fs = require("fs");
|
|
39
|
+
var import_node_path = require("path");
|
|
40
|
+
var import_protocol_primitives3 = require("@starkeep/protocol-primitives");
|
|
41
|
+
var import_storage_adapter = require("@starkeep/storage-adapter");
|
|
42
|
+
|
|
43
|
+
// src/serialization.ts
|
|
44
|
+
var import_protocol_primitives = require("@starkeep/protocol-primitives");
|
|
45
|
+
function recordToRow(record) {
|
|
46
|
+
return {
|
|
47
|
+
id: record.id,
|
|
48
|
+
type: record.type,
|
|
49
|
+
created_at: (0, import_protocol_primitives.serializeHLC)(record.createdAt),
|
|
50
|
+
updated_at: (0, import_protocol_primitives.serializeHLC)(record.updatedAt),
|
|
51
|
+
owner_id: record.ownerId,
|
|
52
|
+
deleted_at: record.deletedAt ? (0, import_protocol_primitives.serializeHLC)(record.deletedAt) : null,
|
|
53
|
+
version: record.version,
|
|
54
|
+
content_hash: record.contentHash,
|
|
55
|
+
object_storage_key: record.objectStorageKey,
|
|
56
|
+
mime_type: record.mimeType,
|
|
57
|
+
size_bytes: record.sizeBytes,
|
|
58
|
+
original_filename: record.originalFilename,
|
|
59
|
+
origin_app_id: record.originAppId,
|
|
60
|
+
parent_id: record.parentId
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function rowToRecord(row) {
|
|
64
|
+
return {
|
|
65
|
+
id: (0, import_protocol_primitives.createStarkeepId)(row.id),
|
|
66
|
+
kind: "data",
|
|
67
|
+
type: row.type,
|
|
68
|
+
createdAt: (0, import_protocol_primitives.deserializeHLC)(row.created_at),
|
|
69
|
+
updatedAt: (0, import_protocol_primitives.deserializeHLC)(row.updated_at),
|
|
70
|
+
ownerId: row.owner_id,
|
|
71
|
+
deletedAt: row.deleted_at ? (0, import_protocol_primitives.deserializeHLC)(row.deleted_at) : null,
|
|
72
|
+
version: row.version,
|
|
73
|
+
contentHash: row.content_hash,
|
|
74
|
+
objectStorageKey: row.object_storage_key,
|
|
75
|
+
mimeType: row.mime_type,
|
|
76
|
+
sizeBytes: row.size_bytes,
|
|
77
|
+
originalFilename: row.original_filename,
|
|
78
|
+
originAppId: row.origin_app_id,
|
|
79
|
+
parentId: row.parent_id ? (0, import_protocol_primitives.createStarkeepId)(row.parent_id) : null
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/query-builder.ts
|
|
84
|
+
var import_kysely = require("kysely");
|
|
85
|
+
var FIELD_MAP = {
|
|
86
|
+
id: "id",
|
|
87
|
+
type: "type",
|
|
88
|
+
createdAt: "created_at",
|
|
89
|
+
updatedAt: "updated_at",
|
|
90
|
+
ownerId: "owner_id",
|
|
91
|
+
deletedAt: "deleted_at",
|
|
92
|
+
version: "version",
|
|
93
|
+
contentHash: "content_hash",
|
|
94
|
+
objectStorageKey: "object_storage_key",
|
|
95
|
+
mimeType: "mime_type",
|
|
96
|
+
sizeBytes: "size_bytes",
|
|
97
|
+
originAppId: "origin_app_id",
|
|
98
|
+
parentId: "parent_id",
|
|
99
|
+
originalFilename: "original_filename"
|
|
100
|
+
};
|
|
101
|
+
function mapField(field) {
|
|
102
|
+
return FIELD_MAP[field] ?? field;
|
|
103
|
+
}
|
|
104
|
+
var compiler = new import_kysely.Kysely({
|
|
105
|
+
dialect: {
|
|
106
|
+
createAdapter: () => new import_kysely.SqliteAdapter(),
|
|
107
|
+
createDriver: () => new import_kysely.DummyDriver(),
|
|
108
|
+
createIntrospector: (db) => new import_kysely.SqliteIntrospector(db),
|
|
109
|
+
createQueryCompiler: () => new import_kysely.SqliteQueryCompiler()
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
function buildSelectQuery(query) {
|
|
113
|
+
let qb = compiler.selectFrom("shared_records").selectAll();
|
|
114
|
+
if (query.type) {
|
|
115
|
+
qb = qb.where("type", "=", query.type);
|
|
116
|
+
}
|
|
117
|
+
if (query.filters) {
|
|
118
|
+
for (const filter of query.filters) {
|
|
119
|
+
qb = applyFilter(qb, filter);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (query.cursor) {
|
|
123
|
+
qb = qb.where("id", ">", query.cursor);
|
|
124
|
+
}
|
|
125
|
+
if (query.sort && query.sort.length > 0) {
|
|
126
|
+
for (const s of query.sort) {
|
|
127
|
+
qb = qb.orderBy(mapField(s.field), s.direction === "desc" ? "desc" : "asc");
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
qb = qb.orderBy("id", "asc");
|
|
131
|
+
}
|
|
132
|
+
if (query.limit) {
|
|
133
|
+
qb = qb.limit(query.limit + 1);
|
|
134
|
+
}
|
|
135
|
+
const compiled = qb.compile();
|
|
136
|
+
return { sql: compiled.sql, params: [...compiled.parameters] };
|
|
137
|
+
}
|
|
138
|
+
function applyFilter(qb, filter) {
|
|
139
|
+
const col = mapField(filter.field);
|
|
140
|
+
switch (filter.operator) {
|
|
141
|
+
case "eq":
|
|
142
|
+
return qb.where(col, "=", filter.value);
|
|
143
|
+
case "neq":
|
|
144
|
+
return qb.where(col, "!=", filter.value);
|
|
145
|
+
case "gt":
|
|
146
|
+
return qb.where(col, ">", filter.value);
|
|
147
|
+
case "gte":
|
|
148
|
+
return qb.where(col, ">=", filter.value);
|
|
149
|
+
case "lt":
|
|
150
|
+
return qb.where(col, "<", filter.value);
|
|
151
|
+
case "lte":
|
|
152
|
+
return qb.where(col, "<=", filter.value);
|
|
153
|
+
case "in":
|
|
154
|
+
return qb.where(col, "in", filter.value);
|
|
155
|
+
case "like":
|
|
156
|
+
return qb.where(col, "like", `%${filter.value}%`);
|
|
157
|
+
case "isNull":
|
|
158
|
+
return qb.where((eb) => eb(col, "is", null));
|
|
159
|
+
case "isNotNull":
|
|
160
|
+
return qb.where((eb) => eb(col, "is not", null));
|
|
161
|
+
default:
|
|
162
|
+
return qb;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/schema/bootstrap.ts
|
|
167
|
+
var import_protocol_primitives2 = require("@starkeep/protocol-primitives");
|
|
168
|
+
function initializeLocalSchema(db) {
|
|
169
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
170
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
171
|
+
db.exec(`
|
|
172
|
+
CREATE TABLE IF NOT EXISTS shared_records (
|
|
173
|
+
id TEXT PRIMARY KEY,
|
|
174
|
+
type TEXT NOT NULL,
|
|
175
|
+
created_at TEXT NOT NULL,
|
|
176
|
+
updated_at TEXT NOT NULL,
|
|
177
|
+
owner_id TEXT NOT NULL,
|
|
178
|
+
deleted_at TEXT,
|
|
179
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
180
|
+
content_hash TEXT NOT NULL,
|
|
181
|
+
object_storage_key TEXT NOT NULL,
|
|
182
|
+
mime_type TEXT NOT NULL,
|
|
183
|
+
size_bytes INTEGER NOT NULL,
|
|
184
|
+
original_filename TEXT,
|
|
185
|
+
origin_app_id TEXT NOT NULL,
|
|
186
|
+
parent_id TEXT
|
|
187
|
+
)
|
|
188
|
+
`);
|
|
189
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_shared_records_type ON shared_records(type)");
|
|
190
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_shared_records_origin_app ON shared_records(origin_app_id)");
|
|
191
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_shared_records_parent_id ON shared_records(parent_id)");
|
|
192
|
+
db.exec(
|
|
193
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS uq_shared_records_owner_filename_hash ON shared_records(owner_id, original_filename, content_hash) WHERE deleted_at IS NULL AND original_filename IS NOT NULL"
|
|
194
|
+
);
|
|
195
|
+
db.exec(`
|
|
196
|
+
CREATE TABLE IF NOT EXISTS shared_access_grants (
|
|
197
|
+
app_id TEXT NOT NULL,
|
|
198
|
+
type_id TEXT NOT NULL,
|
|
199
|
+
access TEXT NOT NULL CHECK (access IN ('read', 'readwrite')),
|
|
200
|
+
metadata_write INTEGER NOT NULL DEFAULT 0,
|
|
201
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
202
|
+
PRIMARY KEY (app_id, type_id)
|
|
203
|
+
)
|
|
204
|
+
`);
|
|
205
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_shared_access_grants_app ON shared_access_grants(app_id)");
|
|
206
|
+
db.exec(`
|
|
207
|
+
CREATE TABLE IF NOT EXISTS shared_app_registry (
|
|
208
|
+
app_id TEXT PRIMARY KEY,
|
|
209
|
+
name TEXT NOT NULL,
|
|
210
|
+
version TEXT NOT NULL,
|
|
211
|
+
tier TEXT NOT NULL DEFAULT 'app',
|
|
212
|
+
manifest TEXT NOT NULL,
|
|
213
|
+
status TEXT NOT NULL DEFAULT 'installing',
|
|
214
|
+
hmac_secret TEXT NOT NULL,
|
|
215
|
+
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
216
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
217
|
+
)
|
|
218
|
+
`);
|
|
219
|
+
db.exec(`
|
|
220
|
+
CREATE TABLE IF NOT EXISTS shared_app_install_steps (
|
|
221
|
+
app_id TEXT NOT NULL,
|
|
222
|
+
operation TEXT NOT NULL CHECK (operation IN ('install', 'uninstall')),
|
|
223
|
+
step TEXT NOT NULL,
|
|
224
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'done', 'failed')),
|
|
225
|
+
error TEXT,
|
|
226
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
227
|
+
PRIMARY KEY (app_id, operation, step)
|
|
228
|
+
)
|
|
229
|
+
`);
|
|
230
|
+
db.exec(`
|
|
231
|
+
CREATE TABLE IF NOT EXISTS app_syncable_namespaces (
|
|
232
|
+
app_id TEXT PRIMARY KEY,
|
|
233
|
+
tables_json TEXT NOT NULL,
|
|
234
|
+
files_enabled INTEGER NOT NULL DEFAULT 0,
|
|
235
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
236
|
+
)
|
|
237
|
+
`);
|
|
238
|
+
db.exec(`
|
|
239
|
+
CREATE TABLE IF NOT EXISTS access_policies (
|
|
240
|
+
policy_id TEXT PRIMARY KEY,
|
|
241
|
+
subject_type TEXT NOT NULL,
|
|
242
|
+
subject_id TEXT NOT NULL,
|
|
243
|
+
resource_type TEXT NOT NULL,
|
|
244
|
+
resource_id TEXT NOT NULL,
|
|
245
|
+
permissions TEXT NOT NULL,
|
|
246
|
+
granted_at TEXT NOT NULL,
|
|
247
|
+
expires_at TEXT
|
|
248
|
+
)
|
|
249
|
+
`);
|
|
250
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_access_policies_subject ON access_policies(subject_type, subject_id)");
|
|
251
|
+
for (const c of import_protocol_primitives2.CATEGORIES) {
|
|
252
|
+
if (c.id === "other") continue;
|
|
253
|
+
db.exec((0, import_protocol_primitives2.sqliteMetadataDdl)(c));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/adapter.ts
|
|
258
|
+
var SqliteDatabaseAdapter = class {
|
|
259
|
+
database = null;
|
|
260
|
+
options;
|
|
261
|
+
constructor(options) {
|
|
262
|
+
this.options = options;
|
|
263
|
+
}
|
|
264
|
+
async init() {
|
|
265
|
+
if (this.database) return;
|
|
266
|
+
if (this.options.path !== ":memory:") {
|
|
267
|
+
const dir = (0, import_node_path.dirname)(this.options.path);
|
|
268
|
+
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
269
|
+
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.database = new import_node_sqlite.DatabaseSync(this.options.path);
|
|
273
|
+
initializeLocalSchema(this.database);
|
|
274
|
+
}
|
|
275
|
+
async close() {
|
|
276
|
+
this.database?.close();
|
|
277
|
+
this.database = null;
|
|
278
|
+
}
|
|
279
|
+
async healthCheck() {
|
|
280
|
+
if (!this.database) return false;
|
|
281
|
+
try {
|
|
282
|
+
this.database.prepare("SELECT 1").get();
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
getDatabase() {
|
|
289
|
+
if (!this.database) throw new import_storage_adapter.StorageError("Database not initialized. Call init() first.");
|
|
290
|
+
return this.database;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Returns the raw SQLite connection so sibling subsystems (e.g. the sync
|
|
294
|
+
* engine's change log + state store) can create side tables in the same
|
|
295
|
+
* database file. Callers must only use this after `init()`.
|
|
296
|
+
*/
|
|
297
|
+
getRawDatabase() {
|
|
298
|
+
return this.getDatabase();
|
|
299
|
+
}
|
|
300
|
+
runStmt(sql, ...params) {
|
|
301
|
+
this.getDatabase().prepare(sql).run(...params);
|
|
302
|
+
}
|
|
303
|
+
getRow(sql, ...params) {
|
|
304
|
+
return this.getDatabase().prepare(sql).get(
|
|
305
|
+
...params
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
allRows(sql, ...params) {
|
|
309
|
+
return this.getDatabase().prepare(sql).all(
|
|
310
|
+
...params
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
async put(record) {
|
|
314
|
+
const row = recordToRow(record);
|
|
315
|
+
const columns = Object.keys(row);
|
|
316
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
317
|
+
const updates = columns.filter((column) => column !== "id").map((column) => `${column} = excluded.${column}`).join(", ");
|
|
318
|
+
const sql = `INSERT INTO shared_records (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT(id) DO UPDATE SET ${updates}`;
|
|
319
|
+
this.runStmt(sql, ...Object.values(row));
|
|
320
|
+
}
|
|
321
|
+
async get(id) {
|
|
322
|
+
const row = this.getRow("SELECT * FROM shared_records WHERE id = ?", id);
|
|
323
|
+
return row ? rowToRecord(row) : null;
|
|
324
|
+
}
|
|
325
|
+
async delete(id, hlc) {
|
|
326
|
+
const ts = (0, import_protocol_primitives3.serializeHLC)(hlc);
|
|
327
|
+
this.runStmt(
|
|
328
|
+
"UPDATE shared_records SET deleted_at = ?, updated_at = ? WHERE id = ?",
|
|
329
|
+
ts,
|
|
330
|
+
ts,
|
|
331
|
+
id
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
async query(query) {
|
|
335
|
+
const { sql, params } = buildSelectQuery(query);
|
|
336
|
+
const rows = this.allRows(sql, ...params);
|
|
337
|
+
const limit = query.limit;
|
|
338
|
+
const hasMore = limit ? rows.length > limit : false;
|
|
339
|
+
const resultRows = hasMore ? rows.slice(0, limit) : rows;
|
|
340
|
+
return {
|
|
341
|
+
records: resultRows.map(rowToRecord),
|
|
342
|
+
nextCursor: hasMore ? resultRows[resultRows.length - 1].id : null,
|
|
343
|
+
hasMore
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
async batch(operations) {
|
|
347
|
+
this.getDatabase().exec("BEGIN");
|
|
348
|
+
try {
|
|
349
|
+
for (const operation of operations) {
|
|
350
|
+
if (operation.type === "put") {
|
|
351
|
+
await this.put(operation.record);
|
|
352
|
+
} else {
|
|
353
|
+
await this.delete(operation.id, operation.hlc);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
this.getDatabase().exec("COMMIT");
|
|
357
|
+
} catch (error) {
|
|
358
|
+
this.getDatabase().exec("ROLLBACK");
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async transaction(callback) {
|
|
363
|
+
this.getDatabase().exec("SAVEPOINT starkeep_tx");
|
|
364
|
+
try {
|
|
365
|
+
const transaction = {
|
|
366
|
+
put: async (record) => this.put(record),
|
|
367
|
+
get: async (id) => this.get(id),
|
|
368
|
+
delete: async (id, hlc) => this.delete(id, hlc),
|
|
369
|
+
query: async (query) => this.query(query)
|
|
370
|
+
};
|
|
371
|
+
const result = await callback(transaction);
|
|
372
|
+
this.getDatabase().exec("RELEASE SAVEPOINT starkeep_tx");
|
|
373
|
+
return result;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
this.getDatabase().exec("ROLLBACK TO SAVEPOINT starkeep_tx");
|
|
376
|
+
this.getDatabase().exec("RELEASE SAVEPOINT starkeep_tx");
|
|
377
|
+
throw new import_storage_adapter.TransactionError("Transaction failed", error);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async putMetadata(typeId, row) {
|
|
381
|
+
const table = (0, import_protocol_primitives3.sqliteMetadataTableName)(typeId);
|
|
382
|
+
const cols = ["record_id"];
|
|
383
|
+
const values = [row.recordId];
|
|
384
|
+
for (const [key, value] of Object.entries(row)) {
|
|
385
|
+
if (key === "recordId") continue;
|
|
386
|
+
cols.push(key);
|
|
387
|
+
values.push(value);
|
|
388
|
+
}
|
|
389
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
390
|
+
const updates = cols.filter((c) => c !== "record_id").map((c) => `${c} = excluded.${c}`).join(", ");
|
|
391
|
+
const sql = updates ? `INSERT INTO ${table} (${cols.join(", ")}) VALUES (${placeholders}) ON CONFLICT(record_id) DO UPDATE SET ${updates}` : `INSERT INTO ${table} (${cols.join(", ")}) VALUES (${placeholders}) ON CONFLICT(record_id) DO NOTHING`;
|
|
392
|
+
this.runStmt(sql, ...values);
|
|
393
|
+
}
|
|
394
|
+
async getMetadata(typeId, recordId) {
|
|
395
|
+
const table = (0, import_protocol_primitives3.sqliteMetadataTableName)(typeId);
|
|
396
|
+
const row = this.getRow(
|
|
397
|
+
`SELECT * FROM ${table} WHERE record_id = ?`,
|
|
398
|
+
recordId
|
|
399
|
+
);
|
|
400
|
+
if (!row) return null;
|
|
401
|
+
return columnsToMetadataRow(recordId, row);
|
|
402
|
+
}
|
|
403
|
+
async getMetadataByIds(typeId, recordIds) {
|
|
404
|
+
const result = /* @__PURE__ */ new Map();
|
|
405
|
+
if (recordIds.length === 0) return result;
|
|
406
|
+
const table = (0, import_protocol_primitives3.sqliteMetadataTableName)(typeId);
|
|
407
|
+
const placeholders = recordIds.map(() => "?").join(", ");
|
|
408
|
+
const rows = this.allRows(
|
|
409
|
+
`SELECT * FROM ${table} WHERE record_id IN (${placeholders})`,
|
|
410
|
+
...recordIds
|
|
411
|
+
);
|
|
412
|
+
for (const row of rows) {
|
|
413
|
+
const recordId = row["record_id"];
|
|
414
|
+
result.set(recordId, columnsToMetadataRow(recordId, row));
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
async deleteMetadata(typeId, recordId) {
|
|
419
|
+
const table = (0, import_protocol_primitives3.sqliteMetadataTableName)(typeId);
|
|
420
|
+
this.runStmt(`DELETE FROM ${table} WHERE record_id = ?`, recordId);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
function columnsToMetadataRow(recordId, columns) {
|
|
424
|
+
const row = { recordId };
|
|
425
|
+
for (const [key, value] of Object.entries(columns)) {
|
|
426
|
+
if (key === "record_id") continue;
|
|
427
|
+
row[key] = value;
|
|
428
|
+
}
|
|
429
|
+
return row;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/control-plane-stores.ts
|
|
433
|
+
var import_protocol_primitives4 = require("@starkeep/protocol-primitives");
|
|
434
|
+
function createSqliteAccessPolicyStore(db) {
|
|
435
|
+
return {
|
|
436
|
+
async putPolicy(policy) {
|
|
437
|
+
db.prepare(
|
|
438
|
+
`INSERT INTO access_policies (
|
|
439
|
+
policy_id, subject_type, subject_id, resource_type, resource_id,
|
|
440
|
+
permissions, granted_at, expires_at
|
|
441
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
442
|
+
ON CONFLICT(policy_id) DO UPDATE SET
|
|
443
|
+
subject_type = excluded.subject_type,
|
|
444
|
+
subject_id = excluded.subject_id,
|
|
445
|
+
resource_type = excluded.resource_type,
|
|
446
|
+
resource_id = excluded.resource_id,
|
|
447
|
+
permissions = excluded.permissions,
|
|
448
|
+
granted_at = excluded.granted_at,
|
|
449
|
+
expires_at = excluded.expires_at`
|
|
450
|
+
).run(
|
|
451
|
+
policy.policyId,
|
|
452
|
+
policy.subjectType,
|
|
453
|
+
policy.subjectId,
|
|
454
|
+
policy.resourceType,
|
|
455
|
+
policy.resourceId,
|
|
456
|
+
policy.permissions.join(","),
|
|
457
|
+
(0, import_protocol_primitives4.serializeHLC)(policy.grantedAt),
|
|
458
|
+
policy.expiresAt ? (0, import_protocol_primitives4.serializeHLC)(policy.expiresAt) : null
|
|
459
|
+
);
|
|
460
|
+
},
|
|
461
|
+
async getPolicy(policyId) {
|
|
462
|
+
const row = db.prepare("SELECT * FROM access_policies WHERE policy_id = ?").get(policyId);
|
|
463
|
+
return row ? rowToPolicy(row) : null;
|
|
464
|
+
},
|
|
465
|
+
async listPolicies() {
|
|
466
|
+
const rows = db.prepare("SELECT * FROM access_policies").all();
|
|
467
|
+
return rows.map(rowToPolicy);
|
|
468
|
+
},
|
|
469
|
+
async deletePolicy(policyId) {
|
|
470
|
+
db.prepare("DELETE FROM access_policies WHERE policy_id = ?").run(policyId);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function rowToPolicy(row) {
|
|
475
|
+
return {
|
|
476
|
+
policyId: (0, import_protocol_primitives4.createStarkeepId)(row.policy_id),
|
|
477
|
+
subjectType: row.subject_type,
|
|
478
|
+
subjectId: row.subject_id,
|
|
479
|
+
resourceType: row.resource_type,
|
|
480
|
+
resourceId: row.resource_id,
|
|
481
|
+
permissions: row.permissions.split(",").filter(Boolean),
|
|
482
|
+
grantedAt: (0, import_protocol_primitives4.deserializeHLC)(row.granted_at),
|
|
483
|
+
expiresAt: row.expires_at ? (0, import_protocol_primitives4.deserializeHLC)(row.expires_at) : null
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/app-syncable/namespace.ts
|
|
488
|
+
function normalizeAppId(appId) {
|
|
489
|
+
return appId.toLowerCase().replace(/-/g, "_");
|
|
490
|
+
}
|
|
491
|
+
function appSyncableTableName(appId, tableName) {
|
|
492
|
+
return `${normalizeAppId(appId)}_syncable_${tableName}`;
|
|
493
|
+
}
|
|
494
|
+
function rowToNamespace(r) {
|
|
495
|
+
const tables = JSON.parse(r.tables_json);
|
|
496
|
+
return {
|
|
497
|
+
appId: r.app_id,
|
|
498
|
+
tables,
|
|
499
|
+
filesEnabled: r.files_enabled === 1,
|
|
500
|
+
tableNames: tables.map((t) => t.name)
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function upsertAppSyncableNamespace(db, appId, tables, filesEnabled) {
|
|
504
|
+
db.prepare(
|
|
505
|
+
`INSERT INTO app_syncable_namespaces (app_id, tables_json, files_enabled)
|
|
506
|
+
VALUES (?, ?, ?)
|
|
507
|
+
ON CONFLICT(app_id) DO UPDATE SET
|
|
508
|
+
tables_json = excluded.tables_json,
|
|
509
|
+
files_enabled = excluded.files_enabled`
|
|
510
|
+
).run(appId, JSON.stringify(tables), filesEnabled ? 1 : 0);
|
|
511
|
+
}
|
|
512
|
+
function deleteAppSyncableNamespace(db, appId) {
|
|
513
|
+
db.prepare("DELETE FROM app_syncable_namespaces WHERE app_id = ?").run(appId);
|
|
514
|
+
}
|
|
515
|
+
function getAppSyncableNamespace(db, appId) {
|
|
516
|
+
const row = db.prepare(
|
|
517
|
+
"SELECT app_id, tables_json, files_enabled FROM app_syncable_namespaces WHERE app_id = ?"
|
|
518
|
+
).get(appId);
|
|
519
|
+
if (!row) return null;
|
|
520
|
+
return rowToNamespace(row);
|
|
521
|
+
}
|
|
522
|
+
function listAppSyncableNamespaces(db) {
|
|
523
|
+
const rows = db.prepare(
|
|
524
|
+
"SELECT app_id, tables_json, files_enabled FROM app_syncable_namespaces"
|
|
525
|
+
).all();
|
|
526
|
+
return rows.map(rowToNamespace);
|
|
527
|
+
}
|
|
528
|
+
var SqliteAppSyncableNamespaceStore = class {
|
|
529
|
+
constructor(db) {
|
|
530
|
+
this.db = db;
|
|
531
|
+
}
|
|
532
|
+
get(appId) {
|
|
533
|
+
return getAppSyncableNamespace(this.db, appId);
|
|
534
|
+
}
|
|
535
|
+
list() {
|
|
536
|
+
return listAppSyncableNamespaces(this.db);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// src/app-syncable/apply.ts
|
|
541
|
+
var import_protocol_primitives5 = require("@starkeep/protocol-primitives");
|
|
542
|
+
var SqliteAppSyncableApplier = class {
|
|
543
|
+
constructor(db, namespace) {
|
|
544
|
+
this.db = db;
|
|
545
|
+
this.namespace = namespace;
|
|
546
|
+
}
|
|
547
|
+
apply(entry) {
|
|
548
|
+
const ns = this.namespace.get(entry.appId);
|
|
549
|
+
if (!ns) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
`SqliteAppSyncableApplier: app "${entry.appId}" not installed`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const tableInfo = ns.tables.find((t) => t.name === entry.table);
|
|
555
|
+
if (!tableInfo) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`SqliteAppSyncableApplier: table "${entry.table}" not declared for app "${entry.appId}"`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
const fullName = appSyncableTableName(entry.appId, entry.table);
|
|
561
|
+
const { pkColumns } = tableInfo;
|
|
562
|
+
if (entry.op === "insert") {
|
|
563
|
+
this.applyInsert(fullName, pkColumns, entry);
|
|
564
|
+
} else if (entry.op === "update") {
|
|
565
|
+
this.applyUpdate(fullName, entry);
|
|
566
|
+
} else {
|
|
567
|
+
this.applyDelete(fullName, entry);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
applyInsert(fullName, pkColumns, entry) {
|
|
571
|
+
const row = entry.row ?? {};
|
|
572
|
+
const cols = Object.keys(row);
|
|
573
|
+
if (cols.length === 0) return;
|
|
574
|
+
const colList = cols.map(q).join(", ");
|
|
575
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
576
|
+
const values = cols.map((c) => row[c]);
|
|
577
|
+
if (pkColumns.length === 0) {
|
|
578
|
+
this.db.prepare(`INSERT OR IGNORE INTO ${q(fullName)} (${colList}) VALUES (${placeholders})`).run(...values);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const conflictTarget = pkColumns.map(q).join(", ");
|
|
582
|
+
const updateCols = cols.filter((c) => !pkColumns.includes(c));
|
|
583
|
+
if (updateCols.length === 0) {
|
|
584
|
+
this.db.prepare(
|
|
585
|
+
`INSERT OR IGNORE INTO ${q(fullName)} (${colList}) VALUES (${placeholders})`
|
|
586
|
+
).run(...values);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const setClause = updateCols.map((c) => `${q(c)} = excluded.${q(c)}`).join(", ");
|
|
590
|
+
this.db.prepare(
|
|
591
|
+
`INSERT INTO ${q(fullName)} (${colList}) VALUES (${placeholders})
|
|
592
|
+
ON CONFLICT(${conflictTarget}) DO UPDATE SET ${setClause}
|
|
593
|
+
WHERE excluded.updated_at > ${q(fullName)}.updated_at`
|
|
594
|
+
).run(...values);
|
|
595
|
+
}
|
|
596
|
+
applyUpdate(fullName, entry) {
|
|
597
|
+
const patch = entry.row ?? {};
|
|
598
|
+
const where = entry.where ?? {};
|
|
599
|
+
const patchCols = Object.keys(patch);
|
|
600
|
+
const whereCols = Object.keys(where);
|
|
601
|
+
if (patchCols.length === 0) return;
|
|
602
|
+
const setClause = patchCols.map((c) => `${q(c)} = ?`).join(", ");
|
|
603
|
+
const incomingUpdatedAt = patch["updated_at"];
|
|
604
|
+
const conditions = whereCols.map((c) => `${q(c)} = ?`);
|
|
605
|
+
if (incomingUpdatedAt) conditions.push(`updated_at < ?`);
|
|
606
|
+
const whereClause = conditions.length ? " WHERE " + conditions.join(" AND ") : "";
|
|
607
|
+
const params = [
|
|
608
|
+
...patchCols.map((c) => patch[c]),
|
|
609
|
+
...whereCols.map((c) => where[c])
|
|
610
|
+
];
|
|
611
|
+
if (incomingUpdatedAt) params.push(incomingUpdatedAt);
|
|
612
|
+
this.db.prepare(`UPDATE ${q(fullName)} SET ${setClause}${whereClause}`).run(...params);
|
|
613
|
+
}
|
|
614
|
+
applyDelete(fullName, entry) {
|
|
615
|
+
const where = entry.where ?? {};
|
|
616
|
+
const whereCols = Object.keys(where);
|
|
617
|
+
const incomingUpdatedAt = entry.row?.["updated_at"];
|
|
618
|
+
const ts = incomingUpdatedAt ?? (0, import_protocol_primitives5.serializeHLC)(entry.timestamp);
|
|
619
|
+
const conditions = [
|
|
620
|
+
...whereCols.map((c) => `${q(c)} = ?`),
|
|
621
|
+
`(updated_at IS NULL OR updated_at < ?)`
|
|
622
|
+
];
|
|
623
|
+
const whereClause = " WHERE " + conditions.join(" AND ");
|
|
624
|
+
const params = [
|
|
625
|
+
ts,
|
|
626
|
+
ts,
|
|
627
|
+
...whereCols.map((c) => where[c]),
|
|
628
|
+
ts
|
|
629
|
+
// for the LWW updated_at < ? condition
|
|
630
|
+
];
|
|
631
|
+
this.db.prepare(
|
|
632
|
+
`UPDATE ${q(fullName)} SET deleted_at = ?, updated_at = ?${whereClause}`
|
|
633
|
+
).run(...params);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Pull-side synthesis: return rows updated after `sinceHlcStr` (or `cursor`
|
|
637
|
+
* if higher) in HLC order, paginated. `updated_at` is a serialized HLC
|
|
638
|
+
* whose lexicographic order matches HLC order (fixed-width hex), and each
|
|
639
|
+
* row's HLC is unique per node, so it doubles as the cursor — no separate
|
|
640
|
+
* tiebreaker column is needed.
|
|
641
|
+
*/
|
|
642
|
+
async scanSince(appId, table, sinceHlcStr, options) {
|
|
643
|
+
const fullName = appSyncableTableName(appId, table);
|
|
644
|
+
const floor = options?.cursor !== void 0 && options.cursor > sinceHlcStr ? options.cursor : sinceHlcStr;
|
|
645
|
+
const limit = options?.limit;
|
|
646
|
+
let rows;
|
|
647
|
+
try {
|
|
648
|
+
if (limit !== void 0) {
|
|
649
|
+
rows = this.db.prepare(
|
|
650
|
+
`SELECT * FROM ${q(fullName)} WHERE updated_at > ? ORDER BY updated_at ASC LIMIT ?`
|
|
651
|
+
).all(floor, limit + 1);
|
|
652
|
+
} else {
|
|
653
|
+
rows = this.db.prepare(
|
|
654
|
+
`SELECT * FROM ${q(fullName)} WHERE updated_at > ? ORDER BY updated_at ASC`
|
|
655
|
+
).all(floor);
|
|
656
|
+
}
|
|
657
|
+
} catch {
|
|
658
|
+
return { rows: [], nextCursor: null, hasMore: false };
|
|
659
|
+
}
|
|
660
|
+
const hasMore = limit !== void 0 && rows.length > limit;
|
|
661
|
+
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
662
|
+
const entries = pageRows.map((row) => rowToEntry(appId, table, row));
|
|
663
|
+
const nextCursor = hasMore && pageRows.length > 0 ? pageRows[pageRows.length - 1]["updated_at"] : null;
|
|
664
|
+
return { rows: entries, nextCursor, hasMore };
|
|
665
|
+
}
|
|
666
|
+
/** Support read path from the factory's queryRows. */
|
|
667
|
+
queryRows(appId, table, where) {
|
|
668
|
+
const fullName = appSyncableTableName(appId, table);
|
|
669
|
+
const whereCols = where ? Object.keys(where) : [];
|
|
670
|
+
const whereClause = [
|
|
671
|
+
"deleted_at IS NULL",
|
|
672
|
+
...whereCols.map((c) => `${q(c)} = ?`)
|
|
673
|
+
].join(" AND ");
|
|
674
|
+
return this.db.prepare(`SELECT * FROM ${q(fullName)} WHERE ${whereClause}`).all(...whereCols.map((c) => where[c]));
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
function q(name) {
|
|
678
|
+
return `"${name}"`;
|
|
679
|
+
}
|
|
680
|
+
function rowToEntry(appId, table, row) {
|
|
681
|
+
const updatedAtStr = row["updated_at"];
|
|
682
|
+
const deletedAtStr = row["deleted_at"];
|
|
683
|
+
const timestamp = (0, import_protocol_primitives5.deserializeHLC)(updatedAtStr);
|
|
684
|
+
if (deletedAtStr) {
|
|
685
|
+
return { timestamp, appId, table, op: "delete", row };
|
|
686
|
+
}
|
|
687
|
+
return { timestamp, appId, table, op: "insert", row };
|
|
688
|
+
}
|
|
689
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
690
|
+
0 && (module.exports = {
|
|
691
|
+
SqliteAppSyncableApplier,
|
|
692
|
+
SqliteAppSyncableNamespaceStore,
|
|
693
|
+
SqliteDatabaseAdapter,
|
|
694
|
+
appSyncableTableName,
|
|
695
|
+
createSqliteAccessPolicyStore,
|
|
696
|
+
deleteAppSyncableNamespace,
|
|
697
|
+
getAppSyncableNamespace,
|
|
698
|
+
initializeLocalSchema,
|
|
699
|
+
listAppSyncableNamespaces,
|
|
700
|
+
upsertAppSyncableNamespace
|
|
701
|
+
});
|
|
702
|
+
//# sourceMappingURL=index.cjs.map
|