@haverstack/record-adapter-sqlite 0.20.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.d.ts +4 -14
- package/dist/index.js +261 -321
- 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: patchContent/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]);
|
|
@@ -848,55 +856,70 @@ var SharedSqlRecordLogic = class {
|
|
|
848
856
|
const associations = this.getAssociationsForRecord(id);
|
|
849
857
|
return rowToRecord(row, associations);
|
|
850
858
|
}
|
|
851
|
-
|
|
859
|
+
/**
|
|
860
|
+
* Apply a change set in one transaction: one UPDATE over the record row
|
|
861
|
+
* with only the columns the change set names, the association set
|
|
862
|
+
* replaced when it carries one, and the snapshot written alongside — so
|
|
863
|
+
* one call is one version whatever it moved.
|
|
864
|
+
*
|
|
865
|
+
* The expectedVersion check is standalone rather than folded into the
|
|
866
|
+
* UPDATE alone, because a content change needs fts.remove() to run
|
|
867
|
+
* *before* the records-table content changes. The guard is still in the
|
|
868
|
+
* UPDATE's WHERE, so a writer that slipped in between the read and the
|
|
869
|
+
* write is caught there rather than overwriting.
|
|
870
|
+
*/
|
|
871
|
+
async mutateRecord(id, changes, opts = {}) {
|
|
852
872
|
const existing = await this.getRecord(id);
|
|
853
873
|
if (!existing)
|
|
854
|
-
throw new
|
|
874
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
855
875
|
this.checkExpectedVersion(existing, opts.expectedVersion);
|
|
856
|
-
const merged = applyMergePatch(existing.content,
|
|
876
|
+
const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
|
|
877
|
+
const sets = [];
|
|
878
|
+
const values = [];
|
|
879
|
+
if (merged !== void 0) {
|
|
880
|
+
sets.push("content = ?");
|
|
881
|
+
values.push(JSON.stringify(merged));
|
|
882
|
+
}
|
|
883
|
+
if (changes.parentId !== void 0) {
|
|
884
|
+
sets.push("parent_id = ?");
|
|
885
|
+
values.push(changes.parentId);
|
|
886
|
+
}
|
|
887
|
+
if (changes.permissions !== void 0) {
|
|
888
|
+
sets.push("permissions = ?");
|
|
889
|
+
values.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null);
|
|
890
|
+
}
|
|
891
|
+
const now = toMs(/* @__PURE__ */ new Date());
|
|
892
|
+
if (changes.unlisted !== void 0) {
|
|
893
|
+
sets.push("unlisted_at = ?");
|
|
894
|
+
values.push(changes.unlisted ? now : null);
|
|
895
|
+
}
|
|
857
896
|
this.exec.transaction(() => {
|
|
858
897
|
if (opts.snapshot)
|
|
859
898
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
899
|
+
if (merged !== void 0)
|
|
900
|
+
fts5Strategy.remove(this.exec, id);
|
|
901
|
+
this.versionedUpdate(id, sets, values, opts, now);
|
|
902
|
+
if (changes.associations !== void 0) {
|
|
903
|
+
this.replaceAssociations(id, changes.associations);
|
|
904
|
+
}
|
|
905
|
+
if (merged !== void 0) {
|
|
906
|
+
fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
|
|
907
|
+
this.syncContentIndex(id, existing.typeId, merged);
|
|
908
|
+
}
|
|
870
909
|
});
|
|
871
|
-
|
|
872
|
-
if (!updated)
|
|
873
|
-
throw new Error(`Record not found after patchContent: "${id}"`);
|
|
874
|
-
return updated;
|
|
910
|
+
return this.reread(id, "mutateRecord");
|
|
875
911
|
}
|
|
876
912
|
async deleteRecord(id, opts = {}) {
|
|
877
913
|
if (opts.hard) {
|
|
878
914
|
return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
|
|
879
|
-
} else {
|
|
880
|
-
this.exec.transaction(() => {
|
|
881
|
-
if (opts.snapshot)
|
|
882
|
-
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
883
|
-
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
884
|
-
const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
885
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
886
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
887
|
-
opts.updatedBy ?? null,
|
|
888
|
-
opts.updatedVia ?? null,
|
|
889
|
-
id,
|
|
890
|
-
...verParams
|
|
891
|
-
]);
|
|
892
|
-
if (changed === 0)
|
|
893
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
894
|
-
});
|
|
895
915
|
}
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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");
|
|
900
923
|
}
|
|
901
924
|
/**
|
|
902
925
|
* Deletes a record's FTS entry, associations, versions, file-refs, sort
|
|
@@ -909,15 +932,12 @@ var SharedSqlRecordLogic = class {
|
|
|
909
932
|
*/
|
|
910
933
|
hardDeleteRecord(id, expectedVersion) {
|
|
911
934
|
const purged = this.readRecord(id);
|
|
912
|
-
if (
|
|
913
|
-
if (
|
|
935
|
+
if (!purged) {
|
|
936
|
+
if (expectedVersion !== void 0)
|
|
914
937
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
915
|
-
if (purged.version !== expectedVersion) {
|
|
916
|
-
throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
if (!purged)
|
|
920
938
|
return null;
|
|
939
|
+
}
|
|
940
|
+
this.checkExpectedVersion(purged, expectedVersion);
|
|
921
941
|
fts5Strategy.remove(this.exec, id);
|
|
922
942
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
923
943
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
@@ -929,79 +949,9 @@ var SharedSqlRecordLogic = class {
|
|
|
929
949
|
this.exec.transaction(() => {
|
|
930
950
|
if (opts.snapshot)
|
|
931
951
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
932
|
-
|
|
933
|
-
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]);
|
|
934
|
-
if (changed === 0)
|
|
935
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
936
|
-
});
|
|
937
|
-
const updated = await this.getRecord(id);
|
|
938
|
-
if (!updated)
|
|
939
|
-
throw new Error(`Record not found after undelete: "${id}"`);
|
|
940
|
-
return updated;
|
|
941
|
-
}
|
|
942
|
-
async setPermissions(id, permissions, opts = {}) {
|
|
943
|
-
this.exec.transaction(() => {
|
|
944
|
-
if (opts.snapshot)
|
|
945
|
-
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
946
|
-
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
947
|
-
const changed = this.exec.run(`UPDATE records SET permissions = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
948
|
-
permissions.length ? JSON.stringify(permissions) : null,
|
|
949
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
950
|
-
opts.updatedBy ?? null,
|
|
951
|
-
opts.updatedVia ?? null,
|
|
952
|
-
id,
|
|
953
|
-
...verParams
|
|
954
|
-
]);
|
|
955
|
-
if (changed === 0)
|
|
956
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
952
|
+
this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
|
|
957
953
|
});
|
|
958
|
-
|
|
959
|
-
if (!updated)
|
|
960
|
-
throw new Error(`Record not found after setPermissions: "${id}"`);
|
|
961
|
-
return updated;
|
|
962
|
-
}
|
|
963
|
-
async setUnlisted(id, unlisted, opts = {}) {
|
|
964
|
-
this.exec.transaction(() => {
|
|
965
|
-
if (opts.snapshot)
|
|
966
|
-
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
967
|
-
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
968
|
-
const now = toMs(/* @__PURE__ */ new Date());
|
|
969
|
-
const changed = this.exec.run(`UPDATE records SET unlisted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
970
|
-
unlisted ? now : null,
|
|
971
|
-
now,
|
|
972
|
-
opts.updatedBy ?? null,
|
|
973
|
-
opts.updatedVia ?? null,
|
|
974
|
-
id,
|
|
975
|
-
...verParams
|
|
976
|
-
]);
|
|
977
|
-
if (changed === 0)
|
|
978
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
979
|
-
});
|
|
980
|
-
const updated = await this.getRecord(id);
|
|
981
|
-
if (!updated)
|
|
982
|
-
throw new Error(`Record not found after setUnlisted: "${id}"`);
|
|
983
|
-
return updated;
|
|
984
|
-
}
|
|
985
|
-
async setParent(id, parentId, opts = {}) {
|
|
986
|
-
this.exec.transaction(() => {
|
|
987
|
-
if (opts.snapshot)
|
|
988
|
-
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
989
|
-
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
990
|
-
const changed = this.exec.run(`UPDATE records SET parent_id = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
991
|
-
parentId,
|
|
992
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
993
|
-
opts.updatedBy ?? null,
|
|
994
|
-
opts.updatedVia ?? null,
|
|
995
|
-
id,
|
|
996
|
-
...verParams
|
|
997
|
-
]);
|
|
998
|
-
if (changed === 0)
|
|
999
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
1000
|
-
});
|
|
1001
|
-
const updated = await this.getRecord(id);
|
|
1002
|
-
if (!updated)
|
|
1003
|
-
throw new Error(`Record not found after setParent: "${id}"`);
|
|
1004
|
-
return updated;
|
|
954
|
+
return this.reread(id, "undelete");
|
|
1005
955
|
}
|
|
1006
956
|
async restoreVersion(id, version, opts = {}) {
|
|
1007
957
|
const existing = await this.getRecord(id);
|
|
@@ -1014,28 +964,12 @@ var SharedSqlRecordLogic = class {
|
|
|
1014
964
|
this.exec.transaction(() => {
|
|
1015
965
|
if (opts.snapshot)
|
|
1016
966
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
1022
|
-
opts.updatedBy ?? null,
|
|
1023
|
-
opts.updatedVia ?? null,
|
|
1024
|
-
target.parentId ?? null,
|
|
1025
|
-
id
|
|
1026
|
-
]);
|
|
1027
|
-
if (target.associations !== void 0) {
|
|
1028
|
-
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
1029
|
-
if (target.associations.length)
|
|
1030
|
-
this.insertAssociations(id, target.associations);
|
|
1031
|
-
}
|
|
1032
|
-
fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
|
|
1033
|
-
this.syncContentIndex(id, target.typeId, target.content);
|
|
967
|
+
this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
|
|
968
|
+
const applied = opts.restoreAssociations === false ? void 0 : target.associations;
|
|
969
|
+
if (applied !== void 0)
|
|
970
|
+
this.replaceAssociations(id, applied);
|
|
1034
971
|
});
|
|
1035
|
-
|
|
1036
|
-
if (!updated)
|
|
1037
|
-
throw new Error(`Record not found after restoreVersion: "${id}"`);
|
|
1038
|
-
return updated;
|
|
972
|
+
return this.reread(id, "restoreVersion");
|
|
1039
973
|
}
|
|
1040
974
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
1041
975
|
const existing = await this.getRecord(id);
|
|
@@ -1045,22 +979,27 @@ var SharedSqlRecordLogic = class {
|
|
|
1045
979
|
this.exec.transaction(() => {
|
|
1046
980
|
if (opts.snapshot)
|
|
1047
981
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
1048
|
-
|
|
1049
|
-
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
|
|
1050
|
-
toTypeId,
|
|
1051
|
-
JSON.stringify(content),
|
|
1052
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
1053
|
-
opts.updatedBy ?? null,
|
|
1054
|
-
opts.updatedVia ?? null,
|
|
1055
|
-
id
|
|
1056
|
-
]);
|
|
1057
|
-
fts5Strategy.insert(this.exec, id, JSON.stringify(content));
|
|
1058
|
-
this.syncContentIndex(id, toTypeId, content);
|
|
982
|
+
this.rewriteContent(id, toTypeId, content, opts);
|
|
1059
983
|
});
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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);
|
|
1064
1003
|
}
|
|
1065
1004
|
async queryRecords(query) {
|
|
1066
1005
|
const limit = query.limit ?? 50;
|
|
@@ -1121,8 +1060,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1121
1060
|
return rows.map(rowToVersion);
|
|
1122
1061
|
}
|
|
1123
1062
|
async getVersion(id, version) {
|
|
1124
|
-
const
|
|
1125
|
-
return
|
|
1063
|
+
const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
1064
|
+
return row ? rowToVersion(row) : null;
|
|
1126
1065
|
}
|
|
1127
1066
|
/**
|
|
1128
1067
|
* A (record_id, version) collision is rejected loudly via the UNIQUE
|
|
@@ -1133,21 +1072,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1133
1072
|
insertVersionRow(id, version) {
|
|
1134
1073
|
try {
|
|
1135
1074
|
this.exec.run(`INSERT INTO versions
|
|
1136
|
-
(record_id, version,
|
|
1137
|
-
|
|
1138
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1139
|
-
id,
|
|
1140
|
-
version.version,
|
|
1141
|
-
version.typeId,
|
|
1142
|
-
JSON.stringify(version.content),
|
|
1143
|
-
toMs(version.updatedAt),
|
|
1144
|
-
version.entityId ?? null,
|
|
1145
|
-
version.updatedBy ?? null,
|
|
1146
|
-
version.updatedVia ?? null,
|
|
1147
|
-
version.parentId ?? null,
|
|
1148
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
1149
|
-
version.permissions ? JSON.stringify(version.permissions) : null
|
|
1150
|
-
]);
|
|
1075
|
+
(record_id, version, ${VERSION_COLUMNS.join(", ")})
|
|
1076
|
+
VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
|
|
1151
1077
|
} catch (err) {
|
|
1152
1078
|
if (isUniqueConstraintViolation(err)) {
|
|
1153
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.`);
|
|
@@ -1156,28 +1082,15 @@ var SharedSqlRecordLogic = class {
|
|
|
1156
1082
|
}
|
|
1157
1083
|
}
|
|
1158
1084
|
/**
|
|
1159
|
-
* Replaces every column insertVersionRow writes
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
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.
|
|
1163
1089
|
*/
|
|
1164
1090
|
overwriteVersionRow(id, version) {
|
|
1165
1091
|
this.exec.run(`UPDATE versions
|
|
1166
|
-
SET
|
|
1167
|
-
|
|
1168
|
-
WHERE record_id = ? AND version = ?`, [
|
|
1169
|
-
version.typeId,
|
|
1170
|
-
JSON.stringify(version.content),
|
|
1171
|
-
toMs(version.updatedAt),
|
|
1172
|
-
version.entityId ?? null,
|
|
1173
|
-
version.updatedBy ?? null,
|
|
1174
|
-
version.updatedVia ?? null,
|
|
1175
|
-
version.parentId ?? null,
|
|
1176
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
1177
|
-
version.permissions ? JSON.stringify(version.permissions) : null,
|
|
1178
|
-
id,
|
|
1179
|
-
version.version
|
|
1180
|
-
]);
|
|
1092
|
+
SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
|
|
1093
|
+
WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
|
|
1181
1094
|
}
|
|
1182
1095
|
/**
|
|
1183
1096
|
* Standalone snapshot write for tooling and tests — loud on any
|
|
@@ -1228,8 +1141,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1228
1141
|
this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
|
|
1229
1142
|
}
|
|
1230
1143
|
async getType(id) {
|
|
1231
|
-
const
|
|
1232
|
-
return
|
|
1144
|
+
const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
|
|
1145
|
+
return row ? rowToType(row) : null;
|
|
1233
1146
|
}
|
|
1234
1147
|
async listTypes() {
|
|
1235
1148
|
const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
|
|
@@ -1242,19 +1155,16 @@ var SharedSqlRecordLogic = class {
|
|
|
1242
1155
|
this.exec.transaction(() => {
|
|
1243
1156
|
if (opts.snapshot)
|
|
1244
1157
|
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
1245
|
-
this.
|
|
1158
|
+
this.versionedUpdate(recordId, [], [], opts);
|
|
1246
1159
|
this.insertAssociations(recordId, [association]);
|
|
1247
1160
|
});
|
|
1248
|
-
|
|
1249
|
-
if (!updated)
|
|
1250
|
-
throw new Error(`Record not found after associate: "${recordId}"`);
|
|
1251
|
-
return updated;
|
|
1161
|
+
return this.reread(recordId, "associate");
|
|
1252
1162
|
}
|
|
1253
1163
|
async dissociate(recordId, association, opts = {}) {
|
|
1254
1164
|
this.exec.transaction(() => {
|
|
1255
1165
|
if (opts.snapshot)
|
|
1256
1166
|
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
1257
|
-
this.
|
|
1167
|
+
this.versionedUpdate(recordId, [], [], opts);
|
|
1258
1168
|
this.exec.run(`DELETE FROM associations
|
|
1259
1169
|
WHERE record_id = ?
|
|
1260
1170
|
AND kind = ?
|
|
@@ -1265,16 +1175,18 @@ var SharedSqlRecordLogic = class {
|
|
|
1265
1175
|
AND related_ns = ?
|
|
1266
1176
|
AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1267
1177
|
});
|
|
1268
|
-
|
|
1269
|
-
if (!updated)
|
|
1270
|
-
throw new Error(`Record not found after dissociate: "${recordId}"`);
|
|
1271
|
-
return updated;
|
|
1178
|
+
return this.reread(recordId, "dissociate");
|
|
1272
1179
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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);
|
|
1278
1190
|
}
|
|
1279
1191
|
/**
|
|
1280
1192
|
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
@@ -1346,6 +1258,43 @@ var SharedSqlRecordLogic = class {
|
|
|
1346
1258
|
return this.getIndexedFields(record.typeId).get(field);
|
|
1347
1259
|
}
|
|
1348
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");
|
|
1349
1298
|
var SharedTokenLogic = class {
|
|
1350
1299
|
deps;
|
|
1351
1300
|
constructor(deps) {
|
|
@@ -1357,7 +1306,7 @@ var SharedTokenLogic = class {
|
|
|
1357
1306
|
async createToken(principalId, opts = {}) {
|
|
1358
1307
|
const id = randomBytes(8).toString("hex");
|
|
1359
1308
|
const token = randomBytes(32).toString("hex");
|
|
1360
|
-
const tokenHash =
|
|
1309
|
+
const tokenHash = hashToken(token);
|
|
1361
1310
|
this.exec.run("INSERT INTO tokens (id, token_hash, principal_id, subject_id, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
|
|
1362
1311
|
id,
|
|
1363
1312
|
tokenHash,
|
|
@@ -1372,7 +1321,7 @@ var SharedTokenLogic = class {
|
|
|
1372
1321
|
return { id, token };
|
|
1373
1322
|
}
|
|
1374
1323
|
async lookupToken(token) {
|
|
1375
|
-
const hash =
|
|
1324
|
+
const hash = hashToken(token);
|
|
1376
1325
|
const row = this.exec.get("SELECT principal_id, subject_id, expires_at FROM tokens WHERE token_hash = ?", [hash]);
|
|
1377
1326
|
if (!row)
|
|
1378
1327
|
return null;
|
|
@@ -1549,8 +1498,8 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
|
|
|
1549
1498
|
getRecord(id) {
|
|
1550
1499
|
return this.record.getRecord(id);
|
|
1551
1500
|
}
|
|
1552
|
-
|
|
1553
|
-
return this.record.
|
|
1501
|
+
mutateRecord(id, changes, opts) {
|
|
1502
|
+
return this.record.mutateRecord(id, changes, opts);
|
|
1554
1503
|
}
|
|
1555
1504
|
deleteRecord(id, opts) {
|
|
1556
1505
|
return this.record.deleteRecord(id, opts);
|
|
@@ -1558,15 +1507,6 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
|
|
|
1558
1507
|
undeleteRecord(id, opts) {
|
|
1559
1508
|
return this.record.undeleteRecord(id, opts);
|
|
1560
1509
|
}
|
|
1561
|
-
setPermissions(id, permissions, opts) {
|
|
1562
|
-
return this.record.setPermissions(id, permissions, opts);
|
|
1563
|
-
}
|
|
1564
|
-
setUnlisted(id, unlisted, opts) {
|
|
1565
|
-
return this.record.setUnlisted(id, unlisted, opts);
|
|
1566
|
-
}
|
|
1567
|
-
setParent(id, parentId, opts) {
|
|
1568
|
-
return this.record.setParent(id, parentId, opts);
|
|
1569
|
-
}
|
|
1570
1510
|
restoreVersion(id, version, opts) {
|
|
1571
1511
|
return this.record.restoreVersion(id, version, opts);
|
|
1572
1512
|
}
|