@haverstack/record-adapter-sqlite 0.21.0 → 0.22.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/dist/index.js +216 -240
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, writeFileSync,
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
2
|
+
import { StackNotFoundError, StackConflictError, applyMergePatch, StackVersionConflictError, StackQueryError, NATIVE_SORT_FIELDS } from '@haverstack/core';
|
|
3
3
|
import { contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
|
|
4
4
|
import { randomBytes, createHash } from 'crypto';
|
|
5
5
|
|
|
@@ -151,7 +151,7 @@ var FTS5_SCHEMA_SQL = `
|
|
|
151
151
|
`;
|
|
152
152
|
var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
|
|
153
153
|
var PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`;
|
|
154
|
-
var SORT_FIELDS =
|
|
154
|
+
var SORT_FIELDS = NATIVE_SORT_FIELDS;
|
|
155
155
|
var toBase64 = (text) => {
|
|
156
156
|
const bytes = new TextEncoder().encode(text);
|
|
157
157
|
let binary = "";
|
|
@@ -367,17 +367,30 @@ var recordConditions = (query) => {
|
|
|
367
367
|
const conditions = ["r.id != '_config'"];
|
|
368
368
|
const params = [];
|
|
369
369
|
const f = query.filter ?? {};
|
|
370
|
+
const inFilter = (column, value) => {
|
|
371
|
+
if (value === void 0)
|
|
372
|
+
return;
|
|
373
|
+
const values = Array.isArray(value) ? value : [value];
|
|
374
|
+
conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
|
|
375
|
+
params.push(...values);
|
|
376
|
+
};
|
|
377
|
+
const rangeFilter = (column, range) => {
|
|
378
|
+
if (range?.after) {
|
|
379
|
+
conditions.push(`r.${column} > ?`);
|
|
380
|
+
params.push(range.after.getTime());
|
|
381
|
+
}
|
|
382
|
+
if (range?.before) {
|
|
383
|
+
conditions.push(`r.${column} < ?`);
|
|
384
|
+
params.push(range.before.getTime());
|
|
385
|
+
}
|
|
386
|
+
};
|
|
370
387
|
if (!f.includeDeleted) {
|
|
371
388
|
conditions.push("r.deleted_at IS NULL");
|
|
372
389
|
}
|
|
373
390
|
if (!f.includeUnlisted) {
|
|
374
391
|
conditions.push("r.unlisted_at IS NULL");
|
|
375
392
|
}
|
|
376
|
-
|
|
377
|
-
const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId];
|
|
378
|
-
conditions.push(`r.type_id IN (${ids.map(() => "?").join(",")})`);
|
|
379
|
-
params.push(...ids);
|
|
380
|
-
}
|
|
393
|
+
inFilter("type_id", f.typeId);
|
|
381
394
|
if (f.parentId !== void 0) {
|
|
382
395
|
if (f.parentId === null) {
|
|
383
396
|
conditions.push("r.parent_id IS NULL");
|
|
@@ -386,49 +399,24 @@ var recordConditions = (query) => {
|
|
|
386
399
|
params.push(f.parentId);
|
|
387
400
|
}
|
|
388
401
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
|
|
396
|
-
conditions.push(`r.entity_id IN (${ids.map(() => "?").join(",")})`);
|
|
397
|
-
params.push(...ids);
|
|
398
|
-
}
|
|
399
|
-
if (f.principalId !== void 0) {
|
|
400
|
-
const ids = Array.isArray(f.principalId) ? f.principalId : [f.principalId];
|
|
401
|
-
conditions.push(`r.principal_id IN (${ids.map(() => "?").join(",")})`);
|
|
402
|
-
params.push(...ids);
|
|
403
|
-
}
|
|
404
|
-
if (f.createdAt?.after) {
|
|
405
|
-
conditions.push("r.created_at > ?");
|
|
406
|
-
params.push(f.createdAt.after.getTime());
|
|
407
|
-
}
|
|
408
|
-
if (f.createdAt?.before) {
|
|
409
|
-
conditions.push("r.created_at < ?");
|
|
410
|
-
params.push(f.createdAt.before.getTime());
|
|
411
|
-
}
|
|
412
|
-
if (f.updatedAt?.after) {
|
|
413
|
-
conditions.push("r.updated_at > ?");
|
|
414
|
-
params.push(f.updatedAt.after.getTime());
|
|
415
|
-
}
|
|
416
|
-
if (f.updatedAt?.before) {
|
|
417
|
-
conditions.push("r.updated_at < ?");
|
|
418
|
-
params.push(f.updatedAt.before.getTime());
|
|
419
|
-
}
|
|
402
|
+
inFilter("app_id", f.appId);
|
|
403
|
+
inFilter("entity_id", f.entityId);
|
|
404
|
+
inFilter("principal_id", f.principalId);
|
|
405
|
+
rangeFilter("created_at", f.createdAt);
|
|
406
|
+
rangeFilter("updated_at", f.updatedAt);
|
|
407
|
+
const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
|
|
420
408
|
if (f.tags?.length) {
|
|
421
409
|
for (const tag of f.tags) {
|
|
422
|
-
conditions.push(
|
|
410
|
+
conditions.push(hasAssociation("tag", ["a.label = ?"]));
|
|
423
411
|
params.push(tag);
|
|
424
412
|
}
|
|
425
413
|
}
|
|
426
414
|
if (f.hasAttachment) {
|
|
427
|
-
conditions.push(
|
|
415
|
+
conditions.push(hasAssociation("attachment", ["a.label = ?"]));
|
|
428
416
|
params.push(f.hasAttachment);
|
|
429
417
|
}
|
|
430
418
|
if (f.attachmentFileId) {
|
|
431
|
-
conditions.push(`(
|
|
419
|
+
conditions.push(`(${hasAssociation("attachment", ["a.file_id = ?"])}
|
|
432
420
|
OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
|
|
433
421
|
params.push(f.attachmentFileId, f.attachmentFileId);
|
|
434
422
|
}
|
|
@@ -457,7 +445,7 @@ var recordConditions = (query) => {
|
|
|
457
445
|
}
|
|
458
446
|
}
|
|
459
447
|
}
|
|
460
|
-
conditions.push(
|
|
448
|
+
conditions.push(hasAssociation("relationship", clauses));
|
|
461
449
|
}
|
|
462
450
|
if (f.content) {
|
|
463
451
|
let aliasSeq = 0;
|
|
@@ -656,46 +644,6 @@ var rowToVersion = (row) => {
|
|
|
656
644
|
v.permissions = JSON.parse(row.permissions);
|
|
657
645
|
return v;
|
|
658
646
|
};
|
|
659
|
-
var lockPathFor = (dbPath) => `${dbPath}.lock`;
|
|
660
|
-
var isProcessAlive = (pid) => {
|
|
661
|
-
try {
|
|
662
|
-
process.kill(pid, 0);
|
|
663
|
-
return true;
|
|
664
|
-
} catch (err) {
|
|
665
|
-
return err.code !== "ESRCH";
|
|
666
|
-
}
|
|
667
|
-
};
|
|
668
|
-
var acquireLock = (dbPath, force) => {
|
|
669
|
-
const lockPath = lockPathFor(dbPath);
|
|
670
|
-
const info = existsSync(lockPath) ? readLock(lockPath) : null;
|
|
671
|
-
if (info) {
|
|
672
|
-
const ownedBySelf = info.pid === process.pid;
|
|
673
|
-
if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
|
|
674
|
-
throw new Error(`Stack database at "${dbPath}" is in use by another process (pid ${info.pid}). Connect via its server instead, or pass { force: true } to override. If no such process is running, remove "${lockPath}".`);
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
writeFileSync(lockPath, JSON.stringify({ pid: process.pid }));
|
|
678
|
-
};
|
|
679
|
-
var readLock = (lockPath) => {
|
|
680
|
-
try {
|
|
681
|
-
const info = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
682
|
-
return typeof info?.pid === "number" ? info : null;
|
|
683
|
-
} catch {
|
|
684
|
-
return null;
|
|
685
|
-
}
|
|
686
|
-
};
|
|
687
|
-
var releaseLock = (dbPath) => {
|
|
688
|
-
const lockPath = lockPathFor(dbPath);
|
|
689
|
-
if (!existsSync(lockPath))
|
|
690
|
-
return;
|
|
691
|
-
try {
|
|
692
|
-
const info = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
693
|
-
if (info.pid === process.pid)
|
|
694
|
-
unlinkSync(lockPath);
|
|
695
|
-
} catch {
|
|
696
|
-
unlinkSync(lockPath);
|
|
697
|
-
}
|
|
698
|
-
};
|
|
699
647
|
|
|
700
648
|
// ../sqlite-shared/dist/executor.js
|
|
701
649
|
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
@@ -723,6 +671,29 @@ var asStackQueryError = (query, run) => {
|
|
|
723
671
|
throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
|
|
724
672
|
}
|
|
725
673
|
};
|
|
674
|
+
var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
|
|
675
|
+
var VERSION_COLUMNS = [
|
|
676
|
+
"type_id",
|
|
677
|
+
"content",
|
|
678
|
+
"updated_at",
|
|
679
|
+
"entity_id",
|
|
680
|
+
"updated_by",
|
|
681
|
+
"updated_via",
|
|
682
|
+
"parent_id",
|
|
683
|
+
"associations",
|
|
684
|
+
"permissions"
|
|
685
|
+
];
|
|
686
|
+
var versionRowValues = (version) => [
|
|
687
|
+
version.typeId,
|
|
688
|
+
JSON.stringify(version.content),
|
|
689
|
+
toMs(version.updatedAt),
|
|
690
|
+
version.entityId ?? null,
|
|
691
|
+
version.updatedBy ?? null,
|
|
692
|
+
version.updatedVia ?? null,
|
|
693
|
+
version.parentId ?? null,
|
|
694
|
+
version.associations ? JSON.stringify(version.associations) : null,
|
|
695
|
+
version.permissions ? JSON.stringify(version.permissions) : null
|
|
696
|
+
];
|
|
726
697
|
var indexedFieldKinds = (schema) => {
|
|
727
698
|
const kinds = /* @__PURE__ */ new Map();
|
|
728
699
|
for (const [field, def] of Object.entries(schema)) {
|
|
@@ -748,26 +719,22 @@ var SharedSqlRecordLogic = class {
|
|
|
748
719
|
return this.deps.exec;
|
|
749
720
|
}
|
|
750
721
|
/**
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
* Precondition check for mutations that can't fold expectedVersion into
|
|
760
|
-
* their primary UPDATE's WHERE clause: mutateRecord/restoreVersion need
|
|
761
|
-
* fts.remove() to run *before* the records-table content changes, so
|
|
762
|
-
* the check happens first, standalone.
|
|
722
|
+
* Precondition check for a mutation that has already read the record —
|
|
723
|
+
* either because it can't fold the check into its own statement
|
|
724
|
+
* (hardDeleteRecord: children reference the row, so the DELETE can't
|
|
725
|
+
* carry it), or because it has to settle the precondition *before*
|
|
726
|
+
* fts5Strategy.remove() runs, which needs the records row still holding
|
|
727
|
+
* the old content. In the latter case versionedUpdate() re-checks it
|
|
728
|
+
* inside the statement, and that is the enforcement; this is what lets
|
|
729
|
+
* the work before it be skipped.
|
|
763
730
|
*/
|
|
764
731
|
checkExpectedVersion(record, expectedVersion) {
|
|
765
732
|
if (expectedVersion === void 0 || record.version === expectedVersion)
|
|
766
733
|
return;
|
|
767
|
-
throw
|
|
734
|
+
throw versionConflict(record.id, expectedVersion, record.version);
|
|
768
735
|
}
|
|
769
736
|
/**
|
|
770
|
-
* Called after a
|
|
737
|
+
* Called after a version-gated statement affected zero rows.
|
|
771
738
|
* Distinguishes "record doesn't exist" (StackNotFoundError) from "it
|
|
772
739
|
* exists but isn't at expectedVersion" (StackVersionConflictError, with
|
|
773
740
|
* the actual current version for the caller to act on).
|
|
@@ -778,7 +745,48 @@ var SharedSqlRecordLogic = class {
|
|
|
778
745
|
]);
|
|
779
746
|
if (!row)
|
|
780
747
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
781
|
-
throw
|
|
748
|
+
throw versionConflict(id, expectedVersion, row.version);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* The one shape every mutating UPDATE in this class has: whatever columns
|
|
752
|
+
* the caller is changing, then the version bump and the actor/timestamp
|
|
753
|
+
* stamp that every mutation owes, gated on the opt-in `expectedVersion`
|
|
754
|
+
* precondition and failing loudly when nothing matched.
|
|
755
|
+
*
|
|
756
|
+
* The guard rides in the WHERE clause even where the caller has already
|
|
757
|
+
* checked the version against a record it read: that read is outside this
|
|
758
|
+
* statement, so a writer slipping in between the two is caught here
|
|
759
|
+
* rather than silently overwritten.
|
|
760
|
+
*/
|
|
761
|
+
versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
|
|
762
|
+
const guarded = opts.expectedVersion !== void 0;
|
|
763
|
+
const changed = this.exec.run(`UPDATE records SET ${[...sets, "version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"].join(", ")} WHERE id = ?${guarded ? " AND version = ?" : ""}`, [
|
|
764
|
+
...values,
|
|
765
|
+
now,
|
|
766
|
+
opts.updatedBy ?? null,
|
|
767
|
+
opts.updatedVia ?? null,
|
|
768
|
+
id,
|
|
769
|
+
...guarded ? [opts.expectedVersion] : []
|
|
770
|
+
]);
|
|
771
|
+
if (changed === 0)
|
|
772
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Read a record back after its own mutation committed, for the caller to
|
|
776
|
+
* return. Its absence is not a "not found" for the caller to handle — the
|
|
777
|
+
* mutation just succeeded against it — so it fails as the invariant
|
|
778
|
+
* breach it would be.
|
|
779
|
+
*
|
|
780
|
+
* Synchronous, via readRecord() rather than getRecord(), so the read
|
|
781
|
+
* cannot be separated from the commit by anything: an async caller
|
|
782
|
+
* returning this still hands back a record read in the same tick its
|
|
783
|
+
* transaction committed. See readRecord() for why that matters.
|
|
784
|
+
*/
|
|
785
|
+
reread(id, verb) {
|
|
786
|
+
const updated = this.readRecord(id);
|
|
787
|
+
if (!updated)
|
|
788
|
+
throw new Error(`Record not found after ${verb}: "${id}"`);
|
|
789
|
+
return updated;
|
|
782
790
|
}
|
|
783
791
|
// -------------------------------------------------------
|
|
784
792
|
// Records
|
|
@@ -835,11 +843,11 @@ var SharedSqlRecordLogic = class {
|
|
|
835
843
|
* reason (see SqlExecutor.transaction).
|
|
836
844
|
*
|
|
837
845
|
* The same synchrony is what lets the post-commit reads below report the
|
|
838
|
-
* version their own mutation produced:
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
* that window, and a concurrent mutation could be
|
|
842
|
-
* one's verb.
|
|
846
|
+
* version their own mutation produced: reread() calls this directly, so
|
|
847
|
+
* nothing interleaves between the commit and the read even though the
|
|
848
|
+
* method returning it is async. A backend that made these reads genuinely
|
|
849
|
+
* asynchronous would open that window, and a concurrent mutation could be
|
|
850
|
+
* reported under this one's verb.
|
|
843
851
|
*/
|
|
844
852
|
readRecord(id) {
|
|
845
853
|
const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
|
|
@@ -890,49 +898,28 @@ var SharedSqlRecordLogic = class {
|
|
|
890
898
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
891
899
|
if (merged !== void 0)
|
|
892
900
|
fts5Strategy.remove(this.exec, id);
|
|
893
|
-
|
|
894
|
-
const changed = this.exec.run(`UPDATE records SET ${sets.concat(["version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"]).join(", ")} WHERE id = ?${clause}`, [...values, now, opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
|
|
895
|
-
if (changed === 0)
|
|
896
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
901
|
+
this.versionedUpdate(id, sets, values, opts, now);
|
|
897
902
|
if (changes.associations !== void 0) {
|
|
898
|
-
this.
|
|
899
|
-
if (changes.associations.length)
|
|
900
|
-
this.insertAssociations(id, changes.associations);
|
|
903
|
+
this.replaceAssociations(id, changes.associations);
|
|
901
904
|
}
|
|
902
905
|
if (merged !== void 0) {
|
|
903
906
|
fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
|
|
904
907
|
this.syncContentIndex(id, existing.typeId, merged);
|
|
905
908
|
}
|
|
906
909
|
});
|
|
907
|
-
|
|
908
|
-
if (!updated)
|
|
909
|
-
throw new Error(`Record not found after mutateRecord: "${id}"`);
|
|
910
|
-
return updated;
|
|
910
|
+
return this.reread(id, "mutateRecord");
|
|
911
911
|
}
|
|
912
912
|
async deleteRecord(id, opts = {}) {
|
|
913
913
|
if (opts.hard) {
|
|
914
914
|
return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
|
|
915
|
-
} else {
|
|
916
|
-
this.exec.transaction(() => {
|
|
917
|
-
if (opts.snapshot)
|
|
918
|
-
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
919
|
-
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
920
|
-
const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
921
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
922
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
923
|
-
opts.updatedBy ?? null,
|
|
924
|
-
opts.updatedVia ?? null,
|
|
925
|
-
id,
|
|
926
|
-
...verParams
|
|
927
|
-
]);
|
|
928
|
-
if (changed === 0)
|
|
929
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
930
|
-
});
|
|
931
915
|
}
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
916
|
+
const now = toMs(/* @__PURE__ */ new Date());
|
|
917
|
+
this.exec.transaction(() => {
|
|
918
|
+
if (opts.snapshot)
|
|
919
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
920
|
+
this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
|
|
921
|
+
});
|
|
922
|
+
return this.reread(id, "deleteRecord");
|
|
936
923
|
}
|
|
937
924
|
/**
|
|
938
925
|
* Deletes a record's FTS entry, associations, versions, file-refs, sort
|
|
@@ -945,15 +932,12 @@ var SharedSqlRecordLogic = class {
|
|
|
945
932
|
*/
|
|
946
933
|
hardDeleteRecord(id, expectedVersion) {
|
|
947
934
|
const purged = this.readRecord(id);
|
|
948
|
-
if (
|
|
949
|
-
if (
|
|
935
|
+
if (!purged) {
|
|
936
|
+
if (expectedVersion !== void 0)
|
|
950
937
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
951
|
-
if (purged.version !== expectedVersion) {
|
|
952
|
-
throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
if (!purged)
|
|
956
938
|
return null;
|
|
939
|
+
}
|
|
940
|
+
this.checkExpectedVersion(purged, expectedVersion);
|
|
957
941
|
fts5Strategy.remove(this.exec, id);
|
|
958
942
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
959
943
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
@@ -965,15 +949,9 @@ var SharedSqlRecordLogic = class {
|
|
|
965
949
|
this.exec.transaction(() => {
|
|
966
950
|
if (opts.snapshot)
|
|
967
951
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
968
|
-
|
|
969
|
-
const changed = this.exec.run(`UPDATE records SET deleted_at = NULL, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
|
|
970
|
-
if (changed === 0)
|
|
971
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
952
|
+
this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
|
|
972
953
|
});
|
|
973
|
-
|
|
974
|
-
if (!updated)
|
|
975
|
-
throw new Error(`Record not found after undelete: "${id}"`);
|
|
976
|
-
return updated;
|
|
954
|
+
return this.reread(id, "undelete");
|
|
977
955
|
}
|
|
978
956
|
async restoreVersion(id, version, opts = {}) {
|
|
979
957
|
const existing = await this.getRecord(id);
|
|
@@ -986,29 +964,12 @@ var SharedSqlRecordLogic = class {
|
|
|
986
964
|
this.exec.transaction(() => {
|
|
987
965
|
if (opts.snapshot)
|
|
988
966
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
989
|
-
|
|
990
|
-
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
|
|
991
|
-
target.typeId,
|
|
992
|
-
JSON.stringify(target.content),
|
|
993
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
994
|
-
opts.updatedBy ?? null,
|
|
995
|
-
opts.updatedVia ?? null,
|
|
996
|
-
target.parentId ?? null,
|
|
997
|
-
id
|
|
998
|
-
]);
|
|
967
|
+
this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
|
|
999
968
|
const applied = opts.restoreAssociations === false ? void 0 : target.associations;
|
|
1000
|
-
if (applied !== void 0)
|
|
1001
|
-
this.
|
|
1002
|
-
if (applied.length)
|
|
1003
|
-
this.insertAssociations(id, applied);
|
|
1004
|
-
}
|
|
1005
|
-
fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
|
|
1006
|
-
this.syncContentIndex(id, target.typeId, target.content);
|
|
969
|
+
if (applied !== void 0)
|
|
970
|
+
this.replaceAssociations(id, applied);
|
|
1007
971
|
});
|
|
1008
|
-
|
|
1009
|
-
if (!updated)
|
|
1010
|
-
throw new Error(`Record not found after restoreVersion: "${id}"`);
|
|
1011
|
-
return updated;
|
|
972
|
+
return this.reread(id, "restoreVersion");
|
|
1012
973
|
}
|
|
1013
974
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1014
975
|
const existing = await this.getRecord(id);
|
|
@@ -1018,22 +979,27 @@ var SharedSqlRecordLogic = class {
|
|
|
1018
979
|
this.exec.transaction(() => {
|
|
1019
980
|
if (opts.snapshot)
|
|
1020
981
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1021
|
-
|
|
1022
|
-
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
|
|
1023
|
-
toTypeId,
|
|
1024
|
-
JSON.stringify(content),
|
|
1025
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
1026
|
-
opts.updatedBy ?? null,
|
|
1027
|
-
opts.updatedVia ?? null,
|
|
1028
|
-
id
|
|
1029
|
-
]);
|
|
1030
|
-
fts5Strategy.insert(this.exec, id, JSON.stringify(content));
|
|
1031
|
-
this.syncContentIndex(id, toTypeId, content);
|
|
982
|
+
this.rewriteContent(id, toTypeId, content, opts);
|
|
1032
983
|
});
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
984
|
+
return this.reread(id, "commitMigration");
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Replace a record's type and content wholesale — what restoreVersion and
|
|
988
|
+
* commitMigration each do, differing only in where the content came from
|
|
989
|
+
* and whether the container moves with it.
|
|
990
|
+
*
|
|
991
|
+
* The order is the constraint: fts5Strategy.remove() has to read the old
|
|
992
|
+
* content off the records row, so it runs before the UPDATE, and the
|
|
993
|
+
* re-index runs after. Every caller must already sit inside
|
|
994
|
+
* exec.transaction() — a failure between the two halves would otherwise
|
|
995
|
+
* leave the record unindexed.
|
|
996
|
+
*/
|
|
997
|
+
rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
|
|
998
|
+
const json = JSON.stringify(content);
|
|
999
|
+
fts5Strategy.remove(this.exec, id);
|
|
1000
|
+
this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
|
|
1001
|
+
fts5Strategy.insert(this.exec, id, json);
|
|
1002
|
+
this.syncContentIndex(id, typeId, content);
|
|
1037
1003
|
}
|
|
1038
1004
|
async queryRecords(query) {
|
|
1039
1005
|
const limit = query.limit ?? 50;
|
|
@@ -1094,8 +1060,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1094
1060
|
return rows.map(rowToVersion);
|
|
1095
1061
|
}
|
|
1096
1062
|
async getVersion(id, version) {
|
|
1097
|
-
const
|
|
1098
|
-
return
|
|
1063
|
+
const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
1064
|
+
return row ? rowToVersion(row) : null;
|
|
1099
1065
|
}
|
|
1100
1066
|
/**
|
|
1101
1067
|
* A (record_id, version) collision is rejected loudly via the UNIQUE
|
|
@@ -1106,21 +1072,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1106
1072
|
insertVersionRow(id, version) {
|
|
1107
1073
|
try {
|
|
1108
1074
|
this.exec.run(`INSERT INTO versions
|
|
1109
|
-
(record_id, version,
|
|
1110
|
-
|
|
1111
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1112
|
-
id,
|
|
1113
|
-
version.version,
|
|
1114
|
-
version.typeId,
|
|
1115
|
-
JSON.stringify(version.content),
|
|
1116
|
-
toMs(version.updatedAt),
|
|
1117
|
-
version.entityId ?? null,
|
|
1118
|
-
version.updatedBy ?? null,
|
|
1119
|
-
version.updatedVia ?? null,
|
|
1120
|
-
version.parentId ?? null,
|
|
1121
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
1122
|
-
version.permissions ? JSON.stringify(version.permissions) : null
|
|
1123
|
-
]);
|
|
1075
|
+
(record_id, version, ${VERSION_COLUMNS.join(", ")})
|
|
1076
|
+
VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
|
|
1124
1077
|
} catch (err) {
|
|
1125
1078
|
if (isUniqueConstraintViolation(err)) {
|
|
1126
1079
|
throw new StackConflictError(`Version ${version.version} already exists for record "${id}" \u2014 a concurrent writer raced past this version. Use ifVersion to detect this before it happens.`);
|
|
@@ -1129,28 +1082,15 @@ var SharedSqlRecordLogic = class {
|
|
|
1129
1082
|
}
|
|
1130
1083
|
}
|
|
1131
1084
|
/**
|
|
1132
|
-
* Replaces every column insertVersionRow writes
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1085
|
+
* Replaces every column insertVersionRow writes — structurally, off the
|
|
1086
|
+
* same VERSION_COLUMNS list, because a column left out here would keep
|
|
1087
|
+
* the replaced row's value and surface later as a restore putting back
|
|
1088
|
+
* something the snapshot never said.
|
|
1136
1089
|
*/
|
|
1137
1090
|
overwriteVersionRow(id, version) {
|
|
1138
1091
|
this.exec.run(`UPDATE versions
|
|
1139
|
-
SET
|
|
1140
|
-
|
|
1141
|
-
WHERE record_id = ? AND version = ?`, [
|
|
1142
|
-
version.typeId,
|
|
1143
|
-
JSON.stringify(version.content),
|
|
1144
|
-
toMs(version.updatedAt),
|
|
1145
|
-
version.entityId ?? null,
|
|
1146
|
-
version.updatedBy ?? null,
|
|
1147
|
-
version.updatedVia ?? null,
|
|
1148
|
-
version.parentId ?? null,
|
|
1149
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
1150
|
-
version.permissions ? JSON.stringify(version.permissions) : null,
|
|
1151
|
-
id,
|
|
1152
|
-
version.version
|
|
1153
|
-
]);
|
|
1092
|
+
SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
|
|
1093
|
+
WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
|
|
1154
1094
|
}
|
|
1155
1095
|
/**
|
|
1156
1096
|
* Standalone snapshot write for tooling and tests — loud on any
|
|
@@ -1201,8 +1141,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1201
1141
|
this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
|
|
1202
1142
|
}
|
|
1203
1143
|
async getType(id) {
|
|
1204
|
-
const
|
|
1205
|
-
return
|
|
1144
|
+
const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
|
|
1145
|
+
return row ? rowToType(row) : null;
|
|
1206
1146
|
}
|
|
1207
1147
|
async listTypes() {
|
|
1208
1148
|
const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
|
|
@@ -1215,19 +1155,16 @@ var SharedSqlRecordLogic = class {
|
|
|
1215
1155
|
this.exec.transaction(() => {
|
|
1216
1156
|
if (opts.snapshot)
|
|
1217
1157
|
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
1218
|
-
this.
|
|
1158
|
+
this.versionedUpdate(recordId, [], [], opts);
|
|
1219
1159
|
this.insertAssociations(recordId, [association]);
|
|
1220
1160
|
});
|
|
1221
|
-
|
|
1222
|
-
if (!updated)
|
|
1223
|
-
throw new Error(`Record not found after associate: "${recordId}"`);
|
|
1224
|
-
return updated;
|
|
1161
|
+
return this.reread(recordId, "associate");
|
|
1225
1162
|
}
|
|
1226
1163
|
async dissociate(recordId, association, opts = {}) {
|
|
1227
1164
|
this.exec.transaction(() => {
|
|
1228
1165
|
if (opts.snapshot)
|
|
1229
1166
|
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
1230
|
-
this.
|
|
1167
|
+
this.versionedUpdate(recordId, [], [], opts);
|
|
1231
1168
|
this.exec.run(`DELETE FROM associations
|
|
1232
1169
|
WHERE record_id = ?
|
|
1233
1170
|
AND kind = ?
|
|
@@ -1238,16 +1175,18 @@ var SharedSqlRecordLogic = class {
|
|
|
1238
1175
|
AND related_ns = ?
|
|
1239
1176
|
AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1240
1177
|
});
|
|
1241
|
-
|
|
1242
|
-
if (!updated)
|
|
1243
|
-
throw new Error(`Record not found after dissociate: "${recordId}"`);
|
|
1244
|
-
return updated;
|
|
1178
|
+
return this.reread(recordId, "dissociate");
|
|
1245
1179
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* Swap a record's association set for `associations` wholesale — the
|
|
1182
|
+
* "replace, don't merge" semantics a change set's association list and a
|
|
1183
|
+
* version snapshot's both carry. Caller-transactional, like every write
|
|
1184
|
+
* below.
|
|
1185
|
+
*/
|
|
1186
|
+
replaceAssociations(recordId, associations) {
|
|
1187
|
+
this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
|
|
1188
|
+
if (associations.length)
|
|
1189
|
+
this.insertAssociations(recordId, associations);
|
|
1251
1190
|
}
|
|
1252
1191
|
/**
|
|
1253
1192
|
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
@@ -1319,6 +1258,43 @@ var SharedSqlRecordLogic = class {
|
|
|
1319
1258
|
return this.getIndexedFields(record.typeId).get(field);
|
|
1320
1259
|
}
|
|
1321
1260
|
};
|
|
1261
|
+
var lockPathFor = (dbPath) => `${dbPath}.lock`;
|
|
1262
|
+
var isProcessAlive = (pid) => {
|
|
1263
|
+
try {
|
|
1264
|
+
process.kill(pid, 0);
|
|
1265
|
+
return true;
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
return err.code !== "ESRCH";
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
var acquireLock = (dbPath, force) => {
|
|
1271
|
+
const lockPath = lockPathFor(dbPath);
|
|
1272
|
+
const info = existsSync(lockPath) ? readLock(lockPath) : null;
|
|
1273
|
+
if (info) {
|
|
1274
|
+
const ownedBySelf = info.pid === process.pid;
|
|
1275
|
+
if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
|
|
1276
|
+
throw new Error(`Stack database at "${dbPath}" is in use by another process (pid ${info.pid}). Connect via its server instead, or pass { force: true } to override. If no such process is running, remove "${lockPath}".`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
writeFileSync(lockPath, JSON.stringify({ pid: process.pid }));
|
|
1280
|
+
};
|
|
1281
|
+
var readLock = (lockPath) => {
|
|
1282
|
+
try {
|
|
1283
|
+
const info = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
1284
|
+
return typeof info?.pid === "number" ? info : null;
|
|
1285
|
+
} catch {
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
var releaseLock = (dbPath) => {
|
|
1290
|
+
const lockPath = lockPathFor(dbPath);
|
|
1291
|
+
if (!existsSync(lockPath))
|
|
1292
|
+
return;
|
|
1293
|
+
const info = readLock(lockPath);
|
|
1294
|
+
if (!info || info.pid === process.pid)
|
|
1295
|
+
unlinkSync(lockPath);
|
|
1296
|
+
};
|
|
1297
|
+
var hashToken = (token) => createHash("sha256").update(token).digest("hex");
|
|
1322
1298
|
var SharedTokenLogic = class {
|
|
1323
1299
|
deps;
|
|
1324
1300
|
constructor(deps) {
|
|
@@ -1330,7 +1306,7 @@ var SharedTokenLogic = class {
|
|
|
1330
1306
|
async createToken(principalId, opts = {}) {
|
|
1331
1307
|
const id = randomBytes(8).toString("hex");
|
|
1332
1308
|
const token = randomBytes(32).toString("hex");
|
|
1333
|
-
const tokenHash =
|
|
1309
|
+
const tokenHash = hashToken(token);
|
|
1334
1310
|
this.exec.run("INSERT INTO tokens (id, token_hash, principal_id, subject_id, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
|
|
1335
1311
|
id,
|
|
1336
1312
|
tokenHash,
|
|
@@ -1345,7 +1321,7 @@ var SharedTokenLogic = class {
|
|
|
1345
1321
|
return { id, token };
|
|
1346
1322
|
}
|
|
1347
1323
|
async lookupToken(token) {
|
|
1348
|
-
const hash =
|
|
1324
|
+
const hash = hashToken(token);
|
|
1349
1325
|
const row = this.exec.get("SELECT principal_id, subject_id, expires_at FROM tokens WHERE token_hash = ?", [hash]);
|
|
1350
1326
|
if (!row)
|
|
1351
1327
|
return null;
|