@haverstack/record-adapter-do-sqlite 0.14.0 → 0.16.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 +118 -53
- package/dist/index.js +260 -276
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { StackNotFoundError, StackConflictError, applyMergePatch, StackVersionConflictError, StackQueryError, NATIVE_SORT_FIELDS } from '@haverstack/core';
|
|
2
2
|
import { contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
|
|
3
3
|
|
|
4
4
|
// ../sqlite-shared/dist/schema.js
|
|
@@ -132,7 +132,12 @@ var FTS5_SCHEMA_SQL = `
|
|
|
132
132
|
);
|
|
133
133
|
`;
|
|
134
134
|
var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
|
|
135
|
-
var
|
|
135
|
+
var applyRecordSchema = (exec, opts) => {
|
|
136
|
+
exec.exec(PRAGMA_FOREIGN_KEYS_ON);
|
|
137
|
+
exec.exec(RECORD_SCHEMA_SQL);
|
|
138
|
+
exec.exec(FTS5_SCHEMA_SQL);
|
|
139
|
+
};
|
|
140
|
+
var SORT_FIELDS = NATIVE_SORT_FIELDS;
|
|
136
141
|
var toBase64 = (text) => {
|
|
137
142
|
const bytes = new TextEncoder().encode(text);
|
|
138
143
|
let binary = "";
|
|
@@ -348,17 +353,30 @@ var recordConditions = (query) => {
|
|
|
348
353
|
const conditions = ["r.id != '_config'"];
|
|
349
354
|
const params = [];
|
|
350
355
|
const f = query.filter ?? {};
|
|
356
|
+
const inFilter = (column, value) => {
|
|
357
|
+
if (value === void 0)
|
|
358
|
+
return;
|
|
359
|
+
const values = Array.isArray(value) ? value : [value];
|
|
360
|
+
conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
|
|
361
|
+
params.push(...values);
|
|
362
|
+
};
|
|
363
|
+
const rangeFilter = (column, range) => {
|
|
364
|
+
if (range?.after) {
|
|
365
|
+
conditions.push(`r.${column} > ?`);
|
|
366
|
+
params.push(range.after.getTime());
|
|
367
|
+
}
|
|
368
|
+
if (range?.before) {
|
|
369
|
+
conditions.push(`r.${column} < ?`);
|
|
370
|
+
params.push(range.before.getTime());
|
|
371
|
+
}
|
|
372
|
+
};
|
|
351
373
|
if (!f.includeDeleted) {
|
|
352
374
|
conditions.push("r.deleted_at IS NULL");
|
|
353
375
|
}
|
|
354
376
|
if (!f.includeUnlisted) {
|
|
355
377
|
conditions.push("r.unlisted_at IS NULL");
|
|
356
378
|
}
|
|
357
|
-
|
|
358
|
-
const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId];
|
|
359
|
-
conditions.push(`r.type_id IN (${ids.map(() => "?").join(",")})`);
|
|
360
|
-
params.push(...ids);
|
|
361
|
-
}
|
|
379
|
+
inFilter("type_id", f.typeId);
|
|
362
380
|
if (f.parentId !== void 0) {
|
|
363
381
|
if (f.parentId === null) {
|
|
364
382
|
conditions.push("r.parent_id IS NULL");
|
|
@@ -367,49 +385,24 @@ var recordConditions = (query) => {
|
|
|
367
385
|
params.push(f.parentId);
|
|
368
386
|
}
|
|
369
387
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
|
|
377
|
-
conditions.push(`r.entity_id IN (${ids.map(() => "?").join(",")})`);
|
|
378
|
-
params.push(...ids);
|
|
379
|
-
}
|
|
380
|
-
if (f.principalId !== void 0) {
|
|
381
|
-
const ids = Array.isArray(f.principalId) ? f.principalId : [f.principalId];
|
|
382
|
-
conditions.push(`r.principal_id IN (${ids.map(() => "?").join(",")})`);
|
|
383
|
-
params.push(...ids);
|
|
384
|
-
}
|
|
385
|
-
if (f.createdAt?.after) {
|
|
386
|
-
conditions.push("r.created_at > ?");
|
|
387
|
-
params.push(f.createdAt.after.getTime());
|
|
388
|
-
}
|
|
389
|
-
if (f.createdAt?.before) {
|
|
390
|
-
conditions.push("r.created_at < ?");
|
|
391
|
-
params.push(f.createdAt.before.getTime());
|
|
392
|
-
}
|
|
393
|
-
if (f.updatedAt?.after) {
|
|
394
|
-
conditions.push("r.updated_at > ?");
|
|
395
|
-
params.push(f.updatedAt.after.getTime());
|
|
396
|
-
}
|
|
397
|
-
if (f.updatedAt?.before) {
|
|
398
|
-
conditions.push("r.updated_at < ?");
|
|
399
|
-
params.push(f.updatedAt.before.getTime());
|
|
400
|
-
}
|
|
388
|
+
inFilter("app_id", f.appId);
|
|
389
|
+
inFilter("entity_id", f.entityId);
|
|
390
|
+
inFilter("principal_id", f.principalId);
|
|
391
|
+
rangeFilter("created_at", f.createdAt);
|
|
392
|
+
rangeFilter("updated_at", f.updatedAt);
|
|
393
|
+
const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
|
|
401
394
|
if (f.tags?.length) {
|
|
402
395
|
for (const tag of f.tags) {
|
|
403
|
-
conditions.push(
|
|
396
|
+
conditions.push(hasAssociation("tag", ["a.label = ?"]));
|
|
404
397
|
params.push(tag);
|
|
405
398
|
}
|
|
406
399
|
}
|
|
407
400
|
if (f.hasAttachment) {
|
|
408
|
-
conditions.push(
|
|
401
|
+
conditions.push(hasAssociation("attachment", ["a.label = ?"]));
|
|
409
402
|
params.push(f.hasAttachment);
|
|
410
403
|
}
|
|
411
404
|
if (f.attachmentFileId) {
|
|
412
|
-
conditions.push(`(
|
|
405
|
+
conditions.push(`(${hasAssociation("attachment", ["a.file_id = ?"])}
|
|
413
406
|
OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
|
|
414
407
|
params.push(f.attachmentFileId, f.attachmentFileId);
|
|
415
408
|
}
|
|
@@ -438,7 +431,7 @@ var recordConditions = (query) => {
|
|
|
438
431
|
}
|
|
439
432
|
}
|
|
440
433
|
}
|
|
441
|
-
conditions.push(
|
|
434
|
+
conditions.push(hasAssociation("relationship", clauses));
|
|
442
435
|
}
|
|
443
436
|
if (f.content) {
|
|
444
437
|
let aliasSeq = 0;
|
|
@@ -643,15 +636,17 @@ var isForeignKeyViolation = (err) => err instanceof Error && err.message.include
|
|
|
643
636
|
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
644
637
|
|
|
645
638
|
// ../sqlite-shared/dist/config.js
|
|
639
|
+
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
646
640
|
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
647
641
|
const now = Date.now();
|
|
648
642
|
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
649
643
|
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
644
|
+
return { entityId, timezone };
|
|
650
645
|
};
|
|
651
|
-
var
|
|
652
|
-
const row = exec.get(
|
|
646
|
+
var tryReadStackConfig = (exec) => {
|
|
647
|
+
const row = exec.get(CONFIG_ROW_SQL);
|
|
653
648
|
if (!row)
|
|
654
|
-
|
|
649
|
+
return null;
|
|
655
650
|
const content = JSON.parse(row.content);
|
|
656
651
|
return { entityId: content.entityId, timezone: content.timezone };
|
|
657
652
|
};
|
|
@@ -664,6 +659,29 @@ var asStackQueryError = (query, run) => {
|
|
|
664
659
|
throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
|
|
665
660
|
}
|
|
666
661
|
};
|
|
662
|
+
var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
|
|
663
|
+
var VERSION_COLUMNS = [
|
|
664
|
+
"type_id",
|
|
665
|
+
"content",
|
|
666
|
+
"updated_at",
|
|
667
|
+
"entity_id",
|
|
668
|
+
"updated_by",
|
|
669
|
+
"updated_via",
|
|
670
|
+
"parent_id",
|
|
671
|
+
"associations",
|
|
672
|
+
"permissions"
|
|
673
|
+
];
|
|
674
|
+
var versionRowValues = (version) => [
|
|
675
|
+
version.typeId,
|
|
676
|
+
JSON.stringify(version.content),
|
|
677
|
+
toMs(version.updatedAt),
|
|
678
|
+
version.entityId ?? null,
|
|
679
|
+
version.updatedBy ?? null,
|
|
680
|
+
version.updatedVia ?? null,
|
|
681
|
+
version.parentId ?? null,
|
|
682
|
+
version.associations ? JSON.stringify(version.associations) : null,
|
|
683
|
+
version.permissions ? JSON.stringify(version.permissions) : null
|
|
684
|
+
];
|
|
667
685
|
var indexedFieldKinds = (schema) => {
|
|
668
686
|
const kinds = /* @__PURE__ */ new Map();
|
|
669
687
|
for (const [field, def] of Object.entries(schema)) {
|
|
@@ -689,26 +707,22 @@ var SharedSqlRecordLogic = class {
|
|
|
689
707
|
return this.deps.exec;
|
|
690
708
|
}
|
|
691
709
|
/**
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
* Precondition check for mutations that can't fold expectedVersion into
|
|
701
|
-
* their primary UPDATE's WHERE clause: mutateRecord/restoreVersion need
|
|
702
|
-
* fts.remove() to run *before* the records-table content changes, so
|
|
703
|
-
* the check happens first, standalone.
|
|
710
|
+
* Precondition check for a mutation that has already read the record —
|
|
711
|
+
* either because it can't fold the check into its own statement
|
|
712
|
+
* (hardDeleteRecord: children reference the row, so the DELETE can't
|
|
713
|
+
* carry it), or because it has to settle the precondition *before*
|
|
714
|
+
* fts5Strategy.remove() runs, which needs the records row still holding
|
|
715
|
+
* the old content. In the latter case versionedUpdate() re-checks it
|
|
716
|
+
* inside the statement, and that is the enforcement; this is what lets
|
|
717
|
+
* the work before it be skipped.
|
|
704
718
|
*/
|
|
705
719
|
checkExpectedVersion(record, expectedVersion) {
|
|
706
720
|
if (expectedVersion === void 0 || record.version === expectedVersion)
|
|
707
721
|
return;
|
|
708
|
-
throw
|
|
722
|
+
throw versionConflict(record.id, expectedVersion, record.version);
|
|
709
723
|
}
|
|
710
724
|
/**
|
|
711
|
-
* Called after a
|
|
725
|
+
* Called after a version-gated statement affected zero rows.
|
|
712
726
|
* Distinguishes "record doesn't exist" (StackNotFoundError) from "it
|
|
713
727
|
* exists but isn't at expectedVersion" (StackVersionConflictError, with
|
|
714
728
|
* the actual current version for the caller to act on).
|
|
@@ -719,7 +733,48 @@ var SharedSqlRecordLogic = class {
|
|
|
719
733
|
]);
|
|
720
734
|
if (!row)
|
|
721
735
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
722
|
-
throw
|
|
736
|
+
throw versionConflict(id, expectedVersion, row.version);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The one shape every mutating UPDATE in this class has: whatever columns
|
|
740
|
+
* the caller is changing, then the version bump and the actor/timestamp
|
|
741
|
+
* stamp that every mutation owes, gated on the opt-in `expectedVersion`
|
|
742
|
+
* precondition and failing loudly when nothing matched.
|
|
743
|
+
*
|
|
744
|
+
* The guard rides in the WHERE clause even where the caller has already
|
|
745
|
+
* checked the version against a record it read: that read is outside this
|
|
746
|
+
* statement, so a writer slipping in between the two is caught here
|
|
747
|
+
* rather than silently overwritten.
|
|
748
|
+
*/
|
|
749
|
+
versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
|
|
750
|
+
const guarded = opts.expectedVersion !== void 0;
|
|
751
|
+
const changed = this.exec.run(`UPDATE records SET ${[...sets, "version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"].join(", ")} WHERE id = ?${guarded ? " AND version = ?" : ""}`, [
|
|
752
|
+
...values,
|
|
753
|
+
now,
|
|
754
|
+
opts.updatedBy ?? null,
|
|
755
|
+
opts.updatedVia ?? null,
|
|
756
|
+
id,
|
|
757
|
+
...guarded ? [opts.expectedVersion] : []
|
|
758
|
+
]);
|
|
759
|
+
if (changed === 0)
|
|
760
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Read a record back after its own mutation committed, for the caller to
|
|
764
|
+
* return. Its absence is not a "not found" for the caller to handle — the
|
|
765
|
+
* mutation just succeeded against it — so it fails as the invariant
|
|
766
|
+
* breach it would be.
|
|
767
|
+
*
|
|
768
|
+
* Synchronous, via readRecord() rather than getRecord(), so the read
|
|
769
|
+
* cannot be separated from the commit by anything: an async caller
|
|
770
|
+
* returning this still hands back a record read in the same tick its
|
|
771
|
+
* transaction committed. See readRecord() for why that matters.
|
|
772
|
+
*/
|
|
773
|
+
reread(id, verb) {
|
|
774
|
+
const updated = this.readRecord(id);
|
|
775
|
+
if (!updated)
|
|
776
|
+
throw new Error(`Record not found after ${verb}: "${id}"`);
|
|
777
|
+
return updated;
|
|
723
778
|
}
|
|
724
779
|
// -------------------------------------------------------
|
|
725
780
|
// Records
|
|
@@ -776,11 +831,11 @@ var SharedSqlRecordLogic = class {
|
|
|
776
831
|
* reason (see SqlExecutor.transaction).
|
|
777
832
|
*
|
|
778
833
|
* The same synchrony is what lets the post-commit reads below report the
|
|
779
|
-
* version their own mutation produced:
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
* that window, and a concurrent mutation could be
|
|
783
|
-
* one's verb.
|
|
834
|
+
* version their own mutation produced: reread() calls this directly, so
|
|
835
|
+
* nothing interleaves between the commit and the read even though the
|
|
836
|
+
* method returning it is async. A backend that made these reads genuinely
|
|
837
|
+
* asynchronous would open that window, and a concurrent mutation could be
|
|
838
|
+
* reported under this one's verb.
|
|
784
839
|
*/
|
|
785
840
|
readRecord(id) {
|
|
786
841
|
const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
|
|
@@ -831,49 +886,28 @@ var SharedSqlRecordLogic = class {
|
|
|
831
886
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
832
887
|
if (merged !== void 0)
|
|
833
888
|
fts5Strategy.remove(this.exec, id);
|
|
834
|
-
|
|
835
|
-
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]);
|
|
836
|
-
if (changed === 0)
|
|
837
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
889
|
+
this.versionedUpdate(id, sets, values, opts, now);
|
|
838
890
|
if (changes.associations !== void 0) {
|
|
839
|
-
this.
|
|
840
|
-
if (changes.associations.length)
|
|
841
|
-
this.insertAssociations(id, changes.associations);
|
|
891
|
+
this.replaceAssociations(id, changes.associations);
|
|
842
892
|
}
|
|
843
893
|
if (merged !== void 0) {
|
|
844
894
|
fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
|
|
845
895
|
this.syncContentIndex(id, existing.typeId, merged);
|
|
846
896
|
}
|
|
847
897
|
});
|
|
848
|
-
|
|
849
|
-
if (!updated)
|
|
850
|
-
throw new Error(`Record not found after mutateRecord: "${id}"`);
|
|
851
|
-
return updated;
|
|
898
|
+
return this.reread(id, "mutateRecord");
|
|
852
899
|
}
|
|
853
900
|
async deleteRecord(id, opts = {}) {
|
|
854
901
|
if (opts.hard) {
|
|
855
902
|
return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
|
|
856
|
-
} else {
|
|
857
|
-
this.exec.transaction(() => {
|
|
858
|
-
if (opts.snapshot)
|
|
859
|
-
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
860
|
-
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
861
|
-
const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
862
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
863
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
864
|
-
opts.updatedBy ?? null,
|
|
865
|
-
opts.updatedVia ?? null,
|
|
866
|
-
id,
|
|
867
|
-
...verParams
|
|
868
|
-
]);
|
|
869
|
-
if (changed === 0)
|
|
870
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
871
|
-
});
|
|
872
903
|
}
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
904
|
+
const now = toMs(/* @__PURE__ */ new Date());
|
|
905
|
+
this.exec.transaction(() => {
|
|
906
|
+
if (opts.snapshot)
|
|
907
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
908
|
+
this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
|
|
909
|
+
});
|
|
910
|
+
return this.reread(id, "deleteRecord");
|
|
877
911
|
}
|
|
878
912
|
/**
|
|
879
913
|
* Deletes a record's FTS entry, associations, versions, file-refs, sort
|
|
@@ -886,15 +920,12 @@ var SharedSqlRecordLogic = class {
|
|
|
886
920
|
*/
|
|
887
921
|
hardDeleteRecord(id, expectedVersion) {
|
|
888
922
|
const purged = this.readRecord(id);
|
|
889
|
-
if (
|
|
890
|
-
if (
|
|
923
|
+
if (!purged) {
|
|
924
|
+
if (expectedVersion !== void 0)
|
|
891
925
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
892
|
-
if (purged.version !== expectedVersion) {
|
|
893
|
-
throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
if (!purged)
|
|
897
926
|
return null;
|
|
927
|
+
}
|
|
928
|
+
this.checkExpectedVersion(purged, expectedVersion);
|
|
898
929
|
fts5Strategy.remove(this.exec, id);
|
|
899
930
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
900
931
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
@@ -906,15 +937,9 @@ var SharedSqlRecordLogic = class {
|
|
|
906
937
|
this.exec.transaction(() => {
|
|
907
938
|
if (opts.snapshot)
|
|
908
939
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
909
|
-
|
|
910
|
-
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]);
|
|
911
|
-
if (changed === 0)
|
|
912
|
-
this.throwVersionConflict(id, opts.expectedVersion);
|
|
940
|
+
this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
|
|
913
941
|
});
|
|
914
|
-
|
|
915
|
-
if (!updated)
|
|
916
|
-
throw new Error(`Record not found after undelete: "${id}"`);
|
|
917
|
-
return updated;
|
|
942
|
+
return this.reread(id, "undelete");
|
|
918
943
|
}
|
|
919
944
|
async restoreVersion(id, version, opts = {}) {
|
|
920
945
|
const existing = await this.getRecord(id);
|
|
@@ -927,29 +952,12 @@ var SharedSqlRecordLogic = class {
|
|
|
927
952
|
this.exec.transaction(() => {
|
|
928
953
|
if (opts.snapshot)
|
|
929
954
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
930
|
-
|
|
931
|
-
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
|
|
932
|
-
target.typeId,
|
|
933
|
-
JSON.stringify(target.content),
|
|
934
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
935
|
-
opts.updatedBy ?? null,
|
|
936
|
-
opts.updatedVia ?? null,
|
|
937
|
-
target.parentId ?? null,
|
|
938
|
-
id
|
|
939
|
-
]);
|
|
955
|
+
this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
|
|
940
956
|
const applied = opts.restoreAssociations === false ? void 0 : target.associations;
|
|
941
|
-
if (applied !== void 0)
|
|
942
|
-
this.
|
|
943
|
-
if (applied.length)
|
|
944
|
-
this.insertAssociations(id, applied);
|
|
945
|
-
}
|
|
946
|
-
fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
|
|
947
|
-
this.syncContentIndex(id, target.typeId, target.content);
|
|
957
|
+
if (applied !== void 0)
|
|
958
|
+
this.replaceAssociations(id, applied);
|
|
948
959
|
});
|
|
949
|
-
|
|
950
|
-
if (!updated)
|
|
951
|
-
throw new Error(`Record not found after restoreVersion: "${id}"`);
|
|
952
|
-
return updated;
|
|
960
|
+
return this.reread(id, "restoreVersion");
|
|
953
961
|
}
|
|
954
962
|
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
955
963
|
const existing = await this.getRecord(id);
|
|
@@ -959,22 +967,27 @@ var SharedSqlRecordLogic = class {
|
|
|
959
967
|
this.exec.transaction(() => {
|
|
960
968
|
if (opts.snapshot)
|
|
961
969
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
962
|
-
|
|
963
|
-
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
|
|
964
|
-
toTypeId,
|
|
965
|
-
JSON.stringify(content),
|
|
966
|
-
toMs(/* @__PURE__ */ new Date()),
|
|
967
|
-
opts.updatedBy ?? null,
|
|
968
|
-
opts.updatedVia ?? null,
|
|
969
|
-
id
|
|
970
|
-
]);
|
|
971
|
-
fts5Strategy.insert(this.exec, id, JSON.stringify(content));
|
|
972
|
-
this.syncContentIndex(id, toTypeId, content);
|
|
970
|
+
this.rewriteContent(id, toTypeId, content, opts);
|
|
973
971
|
});
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
972
|
+
return this.reread(id, "commitMigration");
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Replace a record's type and content wholesale — what restoreVersion and
|
|
976
|
+
* commitMigration each do, differing only in where the content came from
|
|
977
|
+
* and whether the container moves with it.
|
|
978
|
+
*
|
|
979
|
+
* The order is the constraint: fts5Strategy.remove() has to read the old
|
|
980
|
+
* content off the records row, so it runs before the UPDATE, and the
|
|
981
|
+
* re-index runs after. Every caller must already sit inside
|
|
982
|
+
* exec.transaction() — a failure between the two halves would otherwise
|
|
983
|
+
* leave the record unindexed.
|
|
984
|
+
*/
|
|
985
|
+
rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
|
|
986
|
+
const json = JSON.stringify(content);
|
|
987
|
+
fts5Strategy.remove(this.exec, id);
|
|
988
|
+
this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
|
|
989
|
+
fts5Strategy.insert(this.exec, id, json);
|
|
990
|
+
this.syncContentIndex(id, typeId, content);
|
|
978
991
|
}
|
|
979
992
|
async queryRecords(query) {
|
|
980
993
|
const limit = query.limit ?? 50;
|
|
@@ -1035,8 +1048,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1035
1048
|
return rows.map(rowToVersion);
|
|
1036
1049
|
}
|
|
1037
1050
|
async getVersion(id, version) {
|
|
1038
|
-
const
|
|
1039
|
-
return
|
|
1051
|
+
const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
1052
|
+
return row ? rowToVersion(row) : null;
|
|
1040
1053
|
}
|
|
1041
1054
|
/**
|
|
1042
1055
|
* A (record_id, version) collision is rejected loudly via the UNIQUE
|
|
@@ -1047,21 +1060,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1047
1060
|
insertVersionRow(id, version) {
|
|
1048
1061
|
try {
|
|
1049
1062
|
this.exec.run(`INSERT INTO versions
|
|
1050
|
-
(record_id, version,
|
|
1051
|
-
|
|
1052
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1053
|
-
id,
|
|
1054
|
-
version.version,
|
|
1055
|
-
version.typeId,
|
|
1056
|
-
JSON.stringify(version.content),
|
|
1057
|
-
toMs(version.updatedAt),
|
|
1058
|
-
version.entityId ?? null,
|
|
1059
|
-
version.updatedBy ?? null,
|
|
1060
|
-
version.updatedVia ?? null,
|
|
1061
|
-
version.parentId ?? null,
|
|
1062
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
1063
|
-
version.permissions ? JSON.stringify(version.permissions) : null
|
|
1064
|
-
]);
|
|
1063
|
+
(record_id, version, ${VERSION_COLUMNS.join(", ")})
|
|
1064
|
+
VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
|
|
1065
1065
|
} catch (err) {
|
|
1066
1066
|
if (isUniqueConstraintViolation(err)) {
|
|
1067
1067
|
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.`);
|
|
@@ -1070,28 +1070,15 @@ var SharedSqlRecordLogic = class {
|
|
|
1070
1070
|
}
|
|
1071
1071
|
}
|
|
1072
1072
|
/**
|
|
1073
|
-
* Replaces every column insertVersionRow writes
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1073
|
+
* Replaces every column insertVersionRow writes — structurally, off the
|
|
1074
|
+
* same VERSION_COLUMNS list, because a column left out here would keep
|
|
1075
|
+
* the replaced row's value and surface later as a restore putting back
|
|
1076
|
+
* something the snapshot never said.
|
|
1077
1077
|
*/
|
|
1078
1078
|
overwriteVersionRow(id, version) {
|
|
1079
1079
|
this.exec.run(`UPDATE versions
|
|
1080
|
-
SET
|
|
1081
|
-
|
|
1082
|
-
WHERE record_id = ? AND version = ?`, [
|
|
1083
|
-
version.typeId,
|
|
1084
|
-
JSON.stringify(version.content),
|
|
1085
|
-
toMs(version.updatedAt),
|
|
1086
|
-
version.entityId ?? null,
|
|
1087
|
-
version.updatedBy ?? null,
|
|
1088
|
-
version.updatedVia ?? null,
|
|
1089
|
-
version.parentId ?? null,
|
|
1090
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
1091
|
-
version.permissions ? JSON.stringify(version.permissions) : null,
|
|
1092
|
-
id,
|
|
1093
|
-
version.version
|
|
1094
|
-
]);
|
|
1080
|
+
SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
|
|
1081
|
+
WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
|
|
1095
1082
|
}
|
|
1096
1083
|
/**
|
|
1097
1084
|
* Standalone snapshot write for tooling and tests — loud on any
|
|
@@ -1142,8 +1129,8 @@ var SharedSqlRecordLogic = class {
|
|
|
1142
1129
|
this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
|
|
1143
1130
|
}
|
|
1144
1131
|
async getType(id) {
|
|
1145
|
-
const
|
|
1146
|
-
return
|
|
1132
|
+
const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
|
|
1133
|
+
return row ? rowToType(row) : null;
|
|
1147
1134
|
}
|
|
1148
1135
|
async listTypes() {
|
|
1149
1136
|
const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
|
|
@@ -1156,19 +1143,16 @@ var SharedSqlRecordLogic = class {
|
|
|
1156
1143
|
this.exec.transaction(() => {
|
|
1157
1144
|
if (opts.snapshot)
|
|
1158
1145
|
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
1159
|
-
this.
|
|
1146
|
+
this.versionedUpdate(recordId, [], [], opts);
|
|
1160
1147
|
this.insertAssociations(recordId, [association]);
|
|
1161
1148
|
});
|
|
1162
|
-
|
|
1163
|
-
if (!updated)
|
|
1164
|
-
throw new Error(`Record not found after associate: "${recordId}"`);
|
|
1165
|
-
return updated;
|
|
1149
|
+
return this.reread(recordId, "associate");
|
|
1166
1150
|
}
|
|
1167
1151
|
async dissociate(recordId, association, opts = {}) {
|
|
1168
1152
|
this.exec.transaction(() => {
|
|
1169
1153
|
if (opts.snapshot)
|
|
1170
1154
|
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
1171
|
-
this.
|
|
1155
|
+
this.versionedUpdate(recordId, [], [], opts);
|
|
1172
1156
|
this.exec.run(`DELETE FROM associations
|
|
1173
1157
|
WHERE record_id = ?
|
|
1174
1158
|
AND kind = ?
|
|
@@ -1179,16 +1163,18 @@ var SharedSqlRecordLogic = class {
|
|
|
1179
1163
|
AND related_ns = ?
|
|
1180
1164
|
AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1181
1165
|
});
|
|
1182
|
-
|
|
1183
|
-
if (!updated)
|
|
1184
|
-
throw new Error(`Record not found after dissociate: "${recordId}"`);
|
|
1185
|
-
return updated;
|
|
1166
|
+
return this.reread(recordId, "dissociate");
|
|
1186
1167
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1168
|
+
/**
|
|
1169
|
+
* Swap a record's association set for `associations` wholesale — the
|
|
1170
|
+
* "replace, don't merge" semantics a change set's association list and a
|
|
1171
|
+
* version snapshot's both carry. Caller-transactional, like every write
|
|
1172
|
+
* below.
|
|
1173
|
+
*/
|
|
1174
|
+
replaceAssociations(recordId, associations) {
|
|
1175
|
+
this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
|
|
1176
|
+
if (associations.length)
|
|
1177
|
+
this.insertAssociations(recordId, associations);
|
|
1192
1178
|
}
|
|
1193
1179
|
/**
|
|
1194
1180
|
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
@@ -1261,87 +1247,31 @@ var SharedSqlRecordLogic = class {
|
|
|
1261
1247
|
}
|
|
1262
1248
|
};
|
|
1263
1249
|
|
|
1264
|
-
//
|
|
1265
|
-
var
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
return cursor.rowsWritten;
|
|
1279
|
-
}
|
|
1280
|
-
get(sql, params = []) {
|
|
1281
|
-
const rows = this.sql.exec(sql, ...params).toArray();
|
|
1282
|
-
return rows[0];
|
|
1283
|
-
}
|
|
1284
|
-
all(sql, params = []) {
|
|
1285
|
-
return this.sql.exec(sql, ...params).toArray();
|
|
1286
|
-
}
|
|
1287
|
-
transaction(fn) {
|
|
1288
|
-
return this.storage.transactionSync(fn);
|
|
1250
|
+
// ../sqlite-shared/dist/record-adapter.js
|
|
1251
|
+
var SQLITE_RECORD_CAPABILITIES = {
|
|
1252
|
+
filter: {
|
|
1253
|
+
content: "path",
|
|
1254
|
+
contentPresent: true,
|
|
1255
|
+
search: true
|
|
1256
|
+
},
|
|
1257
|
+
sort: {
|
|
1258
|
+
fields: ["createdAt", "updatedAt", "version"],
|
|
1259
|
+
contentField: true
|
|
1260
|
+
},
|
|
1261
|
+
limits: {
|
|
1262
|
+
attachmentBytes: null,
|
|
1263
|
+
contentBytes: null
|
|
1289
1264
|
}
|
|
1290
1265
|
};
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
|
|
1294
|
-
constructor(exec) {
|
|
1295
|
-
this.exec = exec;
|
|
1296
|
-
this.record = new SharedSqlRecordLogic({ exec });
|
|
1297
|
-
}
|
|
1298
|
-
exec;
|
|
1299
|
-
capabilities = {
|
|
1300
|
-
filter: {
|
|
1301
|
-
content: "path",
|
|
1302
|
-
contentPresent: true,
|
|
1303
|
-
search: true
|
|
1304
|
-
},
|
|
1305
|
-
sort: {
|
|
1306
|
-
fields: ["createdAt", "updatedAt", "version"],
|
|
1307
|
-
contentField: true
|
|
1308
|
-
},
|
|
1309
|
-
limits: {
|
|
1310
|
-
attachmentBytes: null,
|
|
1311
|
-
contentBytes: null
|
|
1312
|
-
}
|
|
1313
|
-
};
|
|
1266
|
+
var SharedSqlRecordAdapter = class {
|
|
1267
|
+
capabilities = SQLITE_RECORD_CAPABILITIES;
|
|
1314
1268
|
ownerEntityId;
|
|
1315
1269
|
timezone;
|
|
1316
1270
|
record;
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
* reattach, opts.entityId/timezone ignored in favor of what's stored)
|
|
1322
|
-
* or it doesn't (first call — opts.entityId/timezone become the config).
|
|
1323
|
-
* Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call
|
|
1324
|
-
* is idempotent and cheap.
|
|
1325
|
-
*/
|
|
1326
|
-
static async create(storage, opts) {
|
|
1327
|
-
const exec = new DurableObjectSqliteExecutor(storage);
|
|
1328
|
-
exec.exec(RECORD_SCHEMA_SQL);
|
|
1329
|
-
exec.exec(FTS5_SCHEMA_SQL);
|
|
1330
|
-
exec.exec(PRAGMA_FOREIGN_KEYS_ON);
|
|
1331
|
-
const adapter = new _DoSQLiteRecordAdapter(exec);
|
|
1332
|
-
const existing = exec.get(
|
|
1333
|
-
`SELECT content FROM records WHERE id = '_config'`
|
|
1334
|
-
);
|
|
1335
|
-
if (existing) {
|
|
1336
|
-
const config = readStackConfig(exec);
|
|
1337
|
-
adapter.ownerEntityId = config.entityId;
|
|
1338
|
-
adapter.timezone = config.timezone;
|
|
1339
|
-
} else {
|
|
1340
|
-
insertConfigRecord(exec, opts.entityId, opts.timezone);
|
|
1341
|
-
adapter.ownerEntityId = opts.entityId;
|
|
1342
|
-
adapter.timezone = opts.timezone;
|
|
1343
|
-
}
|
|
1344
|
-
return adapter;
|
|
1271
|
+
constructor(exec, config) {
|
|
1272
|
+
this.record = new SharedSqlRecordLogic({ exec });
|
|
1273
|
+
this.ownerEntityId = config.entityId;
|
|
1274
|
+
this.timezone = config.timezone;
|
|
1345
1275
|
}
|
|
1346
1276
|
// -------------------------------------------------------
|
|
1347
1277
|
// Records
|
|
@@ -1406,6 +1336,60 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
|
|
|
1406
1336
|
dissociate(recordId, association, opts) {
|
|
1407
1337
|
return this.record.dissociate(recordId, association, opts);
|
|
1408
1338
|
}
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
// src/executor.ts
|
|
1342
|
+
var DurableObjectSqliteExecutor = class {
|
|
1343
|
+
constructor(storage) {
|
|
1344
|
+
this.storage = storage;
|
|
1345
|
+
}
|
|
1346
|
+
storage;
|
|
1347
|
+
get sql() {
|
|
1348
|
+
return this.storage.sql;
|
|
1349
|
+
}
|
|
1350
|
+
exec(sql) {
|
|
1351
|
+
this.sql.exec(sql);
|
|
1352
|
+
}
|
|
1353
|
+
run(sql, params = []) {
|
|
1354
|
+
const cursor = this.sql.exec(sql, ...params);
|
|
1355
|
+
return cursor.rowsWritten;
|
|
1356
|
+
}
|
|
1357
|
+
get(sql, params = []) {
|
|
1358
|
+
const rows = this.sql.exec(sql, ...params).toArray();
|
|
1359
|
+
return rows[0];
|
|
1360
|
+
}
|
|
1361
|
+
all(sql, params = []) {
|
|
1362
|
+
return this.sql.exec(sql, ...params).toArray();
|
|
1363
|
+
}
|
|
1364
|
+
transaction(fn) {
|
|
1365
|
+
return this.storage.transactionSync(fn);
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// src/index.ts
|
|
1370
|
+
var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter extends SharedSqlRecordAdapter {
|
|
1371
|
+
constructor(exec, config) {
|
|
1372
|
+
super(exec, config);
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Create (or reattach to) the adapter for a DO instance. There is no
|
|
1376
|
+
* initialize()/open() split the way file-based adapters need one: a DO
|
|
1377
|
+
* id either already has a config record (a previous call created it —
|
|
1378
|
+
* reattach, opts.entityId/timezone ignored in favor of what's stored)
|
|
1379
|
+
* or it doesn't (first call — opts.entityId/timezone become the config).
|
|
1380
|
+
* Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call
|
|
1381
|
+
* is idempotent and cheap.
|
|
1382
|
+
*
|
|
1383
|
+
* DO SQLite manages its own durability and rejects PRAGMA journal_mode
|
|
1384
|
+
* outright ("not authorized") — verified against the real runtime, not
|
|
1385
|
+
* assumed — hence `wal: false`, unlike record-adapter-sqlite.
|
|
1386
|
+
*/
|
|
1387
|
+
static async create(storage, opts) {
|
|
1388
|
+
const exec = new DurableObjectSqliteExecutor(storage);
|
|
1389
|
+
applyRecordSchema(exec);
|
|
1390
|
+
const config = tryReadStackConfig(exec) ?? insertConfigRecord(exec, opts.entityId, opts.timezone);
|
|
1391
|
+
return new _DoSQLiteRecordAdapter(exec, config);
|
|
1392
|
+
}
|
|
1409
1393
|
// -------------------------------------------------------
|
|
1410
1394
|
// Lifecycle
|
|
1411
1395
|
// -------------------------------------------------------
|