@haverstack/record-adapter-do-sqlite 0.16.0 → 0.18.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 +11 -17
- package/dist/index.js +236 -54
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { StackRecord, RecordChanges, RecordVersion, ActorOptions, TypeId, StackQuery, QueryResult, FileId, StackType, Association } from '@haverstack/core';
|
|
1
|
+
import { StackRecord, JournalOptions, RecordChanges, RecordVersion, ActorOptions, TypeId, StackQuery, QueryResult, FileId, JournalQuery, RecordJournalEntry, StackType, Association } from '@haverstack/core';
|
|
2
2
|
import { StackRecordAdapter, AdapterCapabilities } from '@haverstack/core/adapter';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -64,47 +64,41 @@ declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
|
|
|
64
64
|
readonly timezone: string | undefined;
|
|
65
65
|
private readonly record;
|
|
66
66
|
protected constructor(exec: SqlExecutor, config: StackConfig);
|
|
67
|
-
createRecord(record: StackRecord): Promise<StackRecord>;
|
|
67
|
+
createRecord(record: StackRecord, opts?: JournalOptions): Promise<StackRecord>;
|
|
68
68
|
getRecord(id: string): Promise<StackRecord | null>;
|
|
69
69
|
mutateRecord(id: string, changes: RecordChanges, opts?: {
|
|
70
70
|
expectedVersion?: number;
|
|
71
71
|
snapshot?: RecordVersion;
|
|
72
|
-
|
|
72
|
+
bumpsVersion?: boolean;
|
|
73
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
73
74
|
deleteRecord(id: string, opts?: {
|
|
74
75
|
hard?: boolean;
|
|
75
76
|
expectedVersion?: number;
|
|
76
77
|
snapshot?: RecordVersion;
|
|
77
|
-
} & ActorOptions): Promise<StackRecord | null>;
|
|
78
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord | null>;
|
|
78
79
|
undeleteRecord(id: string, opts?: {
|
|
79
80
|
expectedVersion?: number;
|
|
80
81
|
snapshot?: RecordVersion;
|
|
81
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
82
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
82
83
|
restoreVersion(id: string, version: number, opts?: {
|
|
83
84
|
expectedVersion?: number;
|
|
84
85
|
snapshot?: RecordVersion;
|
|
85
|
-
} &
|
|
86
|
-
restoreAssociations?: boolean;
|
|
87
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
86
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
88
87
|
commitMigration(id: string, toTypeId: TypeId, content: Record<string, unknown>, opts?: {
|
|
89
88
|
expectedVersion?: number;
|
|
90
89
|
snapshot?: RecordVersion;
|
|
91
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
90
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
92
91
|
queryRecords(query: StackQuery): Promise<QueryResult>;
|
|
93
92
|
deleteUnreferencedAttachmentRecords(fileId: FileId, metadataTypeIds: TypeId[]): Promise<StackRecord[]>;
|
|
94
93
|
getVersions(id: string): Promise<RecordVersion[]>;
|
|
95
94
|
getVersion(id: string, version: number): Promise<RecordVersion | null>;
|
|
96
95
|
saveVersion(id: string, version: RecordVersion): Promise<void>;
|
|
96
|
+
getJournal(id: string, query?: JournalQuery): Promise<RecordJournalEntry[]>;
|
|
97
97
|
saveType(type: StackType): Promise<void>;
|
|
98
98
|
getType(id: TypeId): Promise<StackType | null>;
|
|
99
99
|
listTypes(): Promise<StackType[]>;
|
|
100
|
-
associate(recordId: string, association: Association, opts?:
|
|
101
|
-
|
|
102
|
-
snapshot?: RecordVersion;
|
|
103
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
104
|
-
dissociate(recordId: string, association: Association, opts?: {
|
|
105
|
-
expectedVersion?: number;
|
|
106
|
-
snapshot?: RecordVersion;
|
|
107
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
100
|
+
associate(recordId: string, association: Association, opts?: JournalOptions): Promise<StackRecord>;
|
|
101
|
+
dissociate(recordId: string, association: Association, opts?: JournalOptions): Promise<StackRecord>;
|
|
108
102
|
abstract flush(): Promise<void>;
|
|
109
103
|
abstract close(): Promise<void>;
|
|
110
104
|
}
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,11 @@ var RECORD_SCHEMA_SQL = `
|
|
|
26
26
|
-- a foreign namespace. All four are in the primary key, so two targets
|
|
27
27
|
-- differing only by namespace are two associations.
|
|
28
28
|
--
|
|
29
|
+
-- attachment_record_id stays out of the primary key: it annotates a
|
|
30
|
+
-- reference rather than naming one, so re-pointing it updates the row an
|
|
31
|
+
-- associate() lands on instead of writing a second reference to the same
|
|
32
|
+
-- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
|
|
33
|
+
--
|
|
29
34
|
-- The primary key leads with record_id, so its implicit index already
|
|
30
35
|
-- serves every by-record read and delete; the indexes below exist only
|
|
31
36
|
-- for the filter directions it cannot answer.
|
|
@@ -38,12 +43,16 @@ var RECORD_SCHEMA_SQL = `
|
|
|
38
43
|
related_id TEXT NOT NULL DEFAULT '',
|
|
39
44
|
related_ns TEXT NOT NULL DEFAULT '',
|
|
40
45
|
related_stack TEXT NOT NULL DEFAULT '',
|
|
46
|
+
attachment_record_id TEXT NOT NULL DEFAULT '',
|
|
41
47
|
PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
|
|
42
48
|
) STRICT;
|
|
43
49
|
|
|
44
50
|
-- parent_id holds the container the record sat in, NULL for the root --
|
|
45
51
|
-- the same encoding records.parent_id uses, since a snapshot states
|
|
46
52
|
-- where the record was rather than an instruction to apply.
|
|
53
|
+
-- No associations column: associate()/dissociate() don't bump version,
|
|
54
|
+
-- so no version ever snapshots the association set. See
|
|
55
|
+
-- docs/spec/versioning.md \xA7 Version history.
|
|
47
56
|
CREATE TABLE IF NOT EXISTS versions (
|
|
48
57
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
49
58
|
version INTEGER NOT NULL,
|
|
@@ -54,11 +63,41 @@ var RECORD_SCHEMA_SQL = `
|
|
|
54
63
|
updated_by TEXT,
|
|
55
64
|
updated_via TEXT,
|
|
56
65
|
parent_id TEXT,
|
|
57
|
-
associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
|
|
58
66
|
permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
|
|
59
67
|
PRIMARY KEY (record_id, version)
|
|
60
68
|
) STRICT;
|
|
61
69
|
|
|
70
|
+
-- The durable half of a change event: which aspects moved, who moved
|
|
71
|
+
-- them, and the association deltas nothing else retains. Envelope-level
|
|
72
|
+
-- by design \u2014 content lives on a versions row, and copying it here
|
|
73
|
+
-- would make the journal the larger of the two stores.
|
|
74
|
+
--
|
|
75
|
+
-- seq is dense from 1 per record and is the only ordering: at is wall
|
|
76
|
+
-- clock, and version stands still across an association change, so
|
|
77
|
+
-- neither orders the log alone. It is allocated inside the appending
|
|
78
|
+
-- write, which is why this table needs none of the collision healing
|
|
79
|
+
-- versions does. See docs/spec/versioning.md \xA7 The change journal.
|
|
80
|
+
CREATE TABLE IF NOT EXISTS journal (
|
|
81
|
+
record_id TEXT NOT NULL REFERENCES records(id),
|
|
82
|
+
seq INTEGER NOT NULL,
|
|
83
|
+
at INTEGER NOT NULL,
|
|
84
|
+
kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted', 'purged')),
|
|
85
|
+
ops TEXT NOT NULL CHECK (json_valid(ops)),
|
|
86
|
+
version INTEGER NOT NULL,
|
|
87
|
+
type_id TEXT NOT NULL,
|
|
88
|
+
parent_id TEXT,
|
|
89
|
+
-- NULL means the entry names no origin (the record did not move);
|
|
90
|
+
-- '' means it moved off the root. parent_id above needs no such
|
|
91
|
+
-- spelling: it is state, where this is a key that may be absent --
|
|
92
|
+
-- the same input/state split parentId follows everywhere.
|
|
93
|
+
previous_parent_id TEXT,
|
|
94
|
+
actor TEXT CHECK (actor IS NULL OR json_valid(actor)),
|
|
95
|
+
associations_added TEXT CHECK (associations_added IS NULL OR json_valid(associations_added)),
|
|
96
|
+
associations_removed TEXT CHECK (associations_removed IS NULL OR json_valid(associations_removed)),
|
|
97
|
+
associations_replaced TEXT CHECK (associations_replaced IS NULL OR json_valid(associations_replaced)),
|
|
98
|
+
PRIMARY KEY (record_id, seq)
|
|
99
|
+
) STRICT;
|
|
100
|
+
|
|
62
101
|
CREATE TABLE IF NOT EXISTS types (
|
|
63
102
|
id TEXT PRIMARY KEY,
|
|
64
103
|
base_id TEXT NOT NULL,
|
|
@@ -561,10 +600,15 @@ var rowToAssociation = (row) => {
|
|
|
561
600
|
return { kind: "tag", label: row.label };
|
|
562
601
|
}
|
|
563
602
|
if (row.kind === "attachment") {
|
|
603
|
+
const attachmentRecordId = row.attachment_record_id;
|
|
564
604
|
return {
|
|
565
605
|
kind: "attachment",
|
|
566
606
|
label: row.label,
|
|
567
|
-
fileId: row.file_id
|
|
607
|
+
fileId: row.file_id,
|
|
608
|
+
// Stored as '' when absent, like every other optional column here —
|
|
609
|
+
// the field is omitted rather than served empty, so a round trip
|
|
610
|
+
// produces the association the caller wrote.
|
|
611
|
+
...attachmentRecordId && { attachmentRecordId }
|
|
568
612
|
};
|
|
569
613
|
}
|
|
570
614
|
return {
|
|
@@ -624,12 +668,37 @@ var rowToVersion = (row) => {
|
|
|
624
668
|
v.updatedVia = row.updated_via;
|
|
625
669
|
if (row.parent_id != null)
|
|
626
670
|
v.parentId = row.parent_id;
|
|
627
|
-
if (row.associations != null)
|
|
628
|
-
v.associations = JSON.parse(row.associations);
|
|
629
671
|
if (row.permissions != null)
|
|
630
672
|
v.permissions = JSON.parse(row.permissions);
|
|
631
673
|
return v;
|
|
632
674
|
};
|
|
675
|
+
var rowToJournalEntry = (row) => {
|
|
676
|
+
const e = {
|
|
677
|
+
seq: row.seq,
|
|
678
|
+
at: fromMs(row.at),
|
|
679
|
+
kind: row.kind,
|
|
680
|
+
ops: JSON.parse(row.ops),
|
|
681
|
+
version: row.version,
|
|
682
|
+
typeId: row.type_id
|
|
683
|
+
};
|
|
684
|
+
if (row.parent_id != null)
|
|
685
|
+
e.parentId = row.parent_id;
|
|
686
|
+
if (row.previous_parent_id != null) {
|
|
687
|
+
e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
|
|
688
|
+
}
|
|
689
|
+
if (row.actor != null)
|
|
690
|
+
e.actor = JSON.parse(row.actor);
|
|
691
|
+
if (row.associations_added != null) {
|
|
692
|
+
e.associationsAdded = JSON.parse(row.associations_added);
|
|
693
|
+
}
|
|
694
|
+
if (row.associations_removed != null) {
|
|
695
|
+
e.associationsRemoved = JSON.parse(row.associations_removed);
|
|
696
|
+
}
|
|
697
|
+
if (row.associations_replaced != null) {
|
|
698
|
+
e.associationsReplaced = JSON.parse(row.associations_replaced);
|
|
699
|
+
}
|
|
700
|
+
return e;
|
|
701
|
+
};
|
|
633
702
|
|
|
634
703
|
// ../sqlite-shared/dist/executor.js
|
|
635
704
|
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
@@ -668,7 +737,6 @@ var VERSION_COLUMNS = [
|
|
|
668
737
|
"updated_by",
|
|
669
738
|
"updated_via",
|
|
670
739
|
"parent_id",
|
|
671
|
-
"associations",
|
|
672
740
|
"permissions"
|
|
673
741
|
];
|
|
674
742
|
var versionRowValues = (version) => [
|
|
@@ -679,7 +747,6 @@ var versionRowValues = (version) => [
|
|
|
679
747
|
version.updatedBy ?? null,
|
|
680
748
|
version.updatedVia ?? null,
|
|
681
749
|
version.parentId ?? null,
|
|
682
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
683
750
|
version.permissions ? JSON.stringify(version.permissions) : null
|
|
684
751
|
];
|
|
685
752
|
var indexedFieldKinds = (schema) => {
|
|
@@ -783,41 +850,51 @@ var SharedSqlRecordLogic = class {
|
|
|
783
850
|
* A duplicate id hits the `records` PK — mapped to StackConflictError
|
|
784
851
|
* instead of surfacing the raw engine exception, mirroring saveVersion's
|
|
785
852
|
* collision mapping below.
|
|
853
|
+
*
|
|
854
|
+
* One transaction, because a record and the four tables that describe it
|
|
855
|
+
* are one fact. A half-applied create is worse than a failed one: the
|
|
856
|
+
* call raises, so the caller believes nothing landed, while the records
|
|
857
|
+
* row survives to fail their retry on the PK — and the row it leaves is
|
|
858
|
+
* invisible to `filter.search` and mis-ordered by `sort.contentField`
|
|
859
|
+
* until something writes it again.
|
|
786
860
|
*/
|
|
787
|
-
async createRecord(record) {
|
|
788
|
-
|
|
789
|
-
|
|
861
|
+
async createRecord(record, opts = {}) {
|
|
862
|
+
this.exec.transaction(() => {
|
|
863
|
+
try {
|
|
864
|
+
this.exec.run(`INSERT INTO records
|
|
790
865
|
(id, type_id, created_at, updated_at, content, version,
|
|
791
866
|
parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
|
|
792
867
|
deleted_at, unlisted_at, permissions)
|
|
793
868
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
869
|
+
record.id,
|
|
870
|
+
record.typeId,
|
|
871
|
+
toMs(record.createdAt),
|
|
872
|
+
toMs(record.updatedAt),
|
|
873
|
+
JSON.stringify(record.content),
|
|
874
|
+
record.version,
|
|
875
|
+
record.parentId ?? null,
|
|
876
|
+
record.entityId ?? null,
|
|
877
|
+
record.appId ?? null,
|
|
878
|
+
record.principalId ?? null,
|
|
879
|
+
record.updatedBy ?? null,
|
|
880
|
+
record.updatedVia ?? null,
|
|
881
|
+
record.deletedAt ? toMs(record.deletedAt) : null,
|
|
882
|
+
record.unlistedAt ? toMs(record.unlistedAt) : null,
|
|
883
|
+
record.permissions ? JSON.stringify(record.permissions) : null
|
|
884
|
+
]);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (isUniqueConstraintViolation(err)) {
|
|
887
|
+
throw new StackConflictError(`Record already exists: "${record.id}"`);
|
|
888
|
+
}
|
|
889
|
+
throw err;
|
|
813
890
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
this.
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
891
|
+
if (record.associations?.length) {
|
|
892
|
+
this.insertAssociations(record.id, record.associations);
|
|
893
|
+
}
|
|
894
|
+
fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
|
|
895
|
+
this.syncContentIndex(record.id, record.typeId, record.content);
|
|
896
|
+
this.appendJournal(record.id, opts.journal);
|
|
897
|
+
});
|
|
821
898
|
return record;
|
|
822
899
|
}
|
|
823
900
|
async getRecord(id) {
|
|
@@ -850,17 +927,40 @@ var SharedSqlRecordLogic = class {
|
|
|
850
927
|
* replaced when it carries one, and the snapshot written alongside — so
|
|
851
928
|
* one call is one version whatever it moved.
|
|
852
929
|
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
930
|
+
* `opts.bumpsVersion: false` is a change set that touches only
|
|
931
|
+
* `associations` — Stack computes this from which ops the set actually
|
|
932
|
+
* moves. Such a write skips the records-table UPDATE (and the snapshot)
|
|
933
|
+
* entirely: nothing on that row changes, so there is nothing to bump and
|
|
934
|
+
* no version to snapshot. `expectedVersion`, if given, is still
|
|
935
|
+
* re-checked synchronously inside the transaction — the CAS guard the
|
|
936
|
+
* bumping path gets from its UPDATE's WHERE clause, restated as a read
|
|
937
|
+
* because there is no UPDATE here to carry it. See
|
|
938
|
+
* docs/spec/versioning.md § Version history.
|
|
939
|
+
*
|
|
940
|
+
* The expectedVersion check for a bumping write is standalone rather than
|
|
941
|
+
* folded into the UPDATE alone, because a content change needs
|
|
942
|
+
* fts.remove() to run *before* the records-table content changes. The
|
|
943
|
+
* guard is still in the UPDATE's WHERE, so a writer that slipped in
|
|
944
|
+
* between the read and the write is caught there rather than overwriting.
|
|
858
945
|
*/
|
|
859
946
|
async mutateRecord(id, changes, opts = {}) {
|
|
860
947
|
const existing = await this.getRecord(id);
|
|
861
948
|
if (!existing)
|
|
862
949
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
863
950
|
this.checkExpectedVersion(existing, opts.expectedVersion);
|
|
951
|
+
if (opts.bumpsVersion === false) {
|
|
952
|
+
this.exec.transaction(() => {
|
|
953
|
+
const current = this.readRecord(id);
|
|
954
|
+
if (!current)
|
|
955
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
956
|
+
this.checkExpectedVersion(current, opts.expectedVersion);
|
|
957
|
+
if (changes.associations !== void 0) {
|
|
958
|
+
this.replaceAssociations(id, changes.associations);
|
|
959
|
+
}
|
|
960
|
+
this.appendJournal(id, opts.journal);
|
|
961
|
+
});
|
|
962
|
+
return this.reread(id, "mutateRecord");
|
|
963
|
+
}
|
|
864
964
|
const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
|
|
865
965
|
const sets = [];
|
|
866
966
|
const values = [];
|
|
@@ -894,6 +994,7 @@ var SharedSqlRecordLogic = class {
|
|
|
894
994
|
fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
|
|
895
995
|
this.syncContentIndex(id, existing.typeId, merged);
|
|
896
996
|
}
|
|
997
|
+
this.appendJournal(id, opts.journal);
|
|
897
998
|
});
|
|
898
999
|
return this.reread(id, "mutateRecord");
|
|
899
1000
|
}
|
|
@@ -906,6 +1007,7 @@ var SharedSqlRecordLogic = class {
|
|
|
906
1007
|
if (opts.snapshot)
|
|
907
1008
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
908
1009
|
this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
|
|
1010
|
+
this.appendJournal(id, opts.journal);
|
|
909
1011
|
});
|
|
910
1012
|
return this.reread(id, "deleteRecord");
|
|
911
1013
|
}
|
|
@@ -929,6 +1031,7 @@ var SharedSqlRecordLogic = class {
|
|
|
929
1031
|
fts5Strategy.remove(this.exec, id);
|
|
930
1032
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
931
1033
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
1034
|
+
this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
|
|
932
1035
|
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
|
|
933
1036
|
this.exec.run("DELETE FROM records WHERE id = ?", [id]);
|
|
934
1037
|
return purged;
|
|
@@ -938,6 +1041,7 @@ var SharedSqlRecordLogic = class {
|
|
|
938
1041
|
if (opts.snapshot)
|
|
939
1042
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
940
1043
|
this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
|
|
1044
|
+
this.appendJournal(id, opts.journal);
|
|
941
1045
|
});
|
|
942
1046
|
return this.reread(id, "undelete");
|
|
943
1047
|
}
|
|
@@ -953,9 +1057,7 @@ var SharedSqlRecordLogic = class {
|
|
|
953
1057
|
if (opts.snapshot)
|
|
954
1058
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
955
1059
|
this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
|
|
956
|
-
|
|
957
|
-
if (applied !== void 0)
|
|
958
|
-
this.replaceAssociations(id, applied);
|
|
1060
|
+
this.appendJournal(id, opts.journal);
|
|
959
1061
|
});
|
|
960
1062
|
return this.reread(id, "restoreVersion");
|
|
961
1063
|
}
|
|
@@ -968,6 +1070,7 @@ var SharedSqlRecordLogic = class {
|
|
|
968
1070
|
if (opts.snapshot)
|
|
969
1071
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
970
1072
|
this.rewriteContent(id, toTypeId, content, opts);
|
|
1073
|
+
this.appendJournal(id, opts.journal);
|
|
971
1074
|
});
|
|
972
1075
|
return this.reread(id, "commitMigration");
|
|
973
1076
|
}
|
|
@@ -1110,6 +1213,59 @@ var SharedSqlRecordLogic = class {
|
|
|
1110
1213
|
}
|
|
1111
1214
|
}
|
|
1112
1215
|
// -------------------------------------------------------
|
|
1216
|
+
// Change journal
|
|
1217
|
+
// -------------------------------------------------------
|
|
1218
|
+
/**
|
|
1219
|
+
* Append one entry, stamping the record-derived half off the row as it
|
|
1220
|
+
* now stands — so every caller must already sit inside the same
|
|
1221
|
+
* transaction as the write it describes, after that write has landed.
|
|
1222
|
+
*
|
|
1223
|
+
* `seq` is allocated here, from the log's own max, rather than computed
|
|
1224
|
+
* by the caller. That is what spares this table the collision healing
|
|
1225
|
+
* versions needs: nothing outside this statement ever holds a seq it
|
|
1226
|
+
* expects to be free. See docs/spec/versioning.md § The change journal.
|
|
1227
|
+
*/
|
|
1228
|
+
appendJournal(recordId, entry) {
|
|
1229
|
+
if (!entry)
|
|
1230
|
+
return;
|
|
1231
|
+
const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
|
|
1232
|
+
if (!row)
|
|
1233
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1234
|
+
this.exec.run(`INSERT INTO journal
|
|
1235
|
+
(record_id, seq, at, kind, ops, version, type_id, parent_id,
|
|
1236
|
+
previous_parent_id, actor, associations_added, associations_removed,
|
|
1237
|
+
associations_replaced)
|
|
1238
|
+
VALUES (
|
|
1239
|
+
?,
|
|
1240
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
|
|
1241
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1242
|
+
recordId,
|
|
1243
|
+
recordId,
|
|
1244
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
1245
|
+
entry.kind,
|
|
1246
|
+
JSON.stringify(entry.ops),
|
|
1247
|
+
row.version,
|
|
1248
|
+
row.type_id,
|
|
1249
|
+
row.parent_id,
|
|
1250
|
+
// '' is the root; NULL is an entry that names no origin at all.
|
|
1251
|
+
entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
|
|
1252
|
+
entry.actor ? JSON.stringify(entry.actor) : null,
|
|
1253
|
+
entry.associationsAdded?.length ? JSON.stringify(entry.associationsAdded) : null,
|
|
1254
|
+
entry.associationsRemoved?.length ? JSON.stringify(entry.associationsRemoved) : null,
|
|
1255
|
+
entry.associationsReplaced?.length ? JSON.stringify(entry.associationsReplaced) : null
|
|
1256
|
+
]);
|
|
1257
|
+
}
|
|
1258
|
+
async getJournal(id, query = {}) {
|
|
1259
|
+
const conditions = ["record_id = ?"];
|
|
1260
|
+
const values = [id];
|
|
1261
|
+
if (query.sinceSeq !== void 0) {
|
|
1262
|
+
conditions.push("seq > ?");
|
|
1263
|
+
values.push(query.sinceSeq);
|
|
1264
|
+
}
|
|
1265
|
+
const rows = this.exec.all(`SELECT * FROM journal WHERE ${conditions.join(" AND ")} ORDER BY seq ASC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
|
|
1266
|
+
return rows.map(rowToJournalEntry);
|
|
1267
|
+
}
|
|
1268
|
+
// -------------------------------------------------------
|
|
1113
1269
|
// Types
|
|
1114
1270
|
// -------------------------------------------------------
|
|
1115
1271
|
async saveType(type) {
|
|
@@ -1139,20 +1295,27 @@ var SharedSqlRecordLogic = class {
|
|
|
1139
1295
|
// -------------------------------------------------------
|
|
1140
1296
|
// Associations
|
|
1141
1297
|
// -------------------------------------------------------
|
|
1298
|
+
/**
|
|
1299
|
+
* Never bumps `version`/`updatedAt` and never snapshots — a set-add
|
|
1300
|
+
* composes correctly regardless of write order, so it needs neither OCC
|
|
1301
|
+
* nor rollback history. The records row is untouched entirely; only the
|
|
1302
|
+
* associations table changes. See docs/spec/versioning.md § Version
|
|
1303
|
+
* history.
|
|
1304
|
+
*/
|
|
1142
1305
|
async associate(recordId, association, opts = {}) {
|
|
1143
1306
|
this.exec.transaction(() => {
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1146
|
-
this.versionedUpdate(recordId, [], [], opts);
|
|
1307
|
+
if (!this.readRecord(recordId))
|
|
1308
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1147
1309
|
this.insertAssociations(recordId, [association]);
|
|
1310
|
+
this.appendJournal(recordId, opts.journal);
|
|
1148
1311
|
});
|
|
1149
1312
|
return this.reread(recordId, "associate");
|
|
1150
1313
|
}
|
|
1314
|
+
/** Never bumps `version`/`updatedAt` — see associate(). */
|
|
1151
1315
|
async dissociate(recordId, association, opts = {}) {
|
|
1152
1316
|
this.exec.transaction(() => {
|
|
1153
|
-
if (
|
|
1154
|
-
|
|
1155
|
-
this.versionedUpdate(recordId, [], [], opts);
|
|
1317
|
+
if (!this.readRecord(recordId))
|
|
1318
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1156
1319
|
this.exec.run(`DELETE FROM associations
|
|
1157
1320
|
WHERE record_id = ?
|
|
1158
1321
|
AND kind = ?
|
|
@@ -1162,6 +1325,7 @@ var SharedSqlRecordLogic = class {
|
|
|
1162
1325
|
AND related_id = ?
|
|
1163
1326
|
AND related_ns = ?
|
|
1164
1327
|
AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1328
|
+
this.appendJournal(recordId, opts.journal);
|
|
1165
1329
|
});
|
|
1166
1330
|
return this.reread(recordId, "dissociate");
|
|
1167
1331
|
}
|
|
@@ -1177,6 +1341,10 @@ var SharedSqlRecordLogic = class {
|
|
|
1177
1341
|
this.insertAssociations(recordId, associations);
|
|
1178
1342
|
}
|
|
1179
1343
|
/**
|
|
1344
|
+
* Upserts on association identity: the primary key decides which row a
|
|
1345
|
+
* write lands on, and `attachment_record_id` — outside it — is whatever
|
|
1346
|
+
* the incoming association says, so re-pointing one costs no second row.
|
|
1347
|
+
*
|
|
1180
1348
|
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
1181
1349
|
* association against a record that doesn't exist throws — mapped
|
|
1182
1350
|
* here to StackNotFoundError so associate() on a nonexistent record
|
|
@@ -1185,9 +1353,17 @@ var SharedSqlRecordLogic = class {
|
|
|
1185
1353
|
insertAssociations(recordId, associations) {
|
|
1186
1354
|
for (const assoc of associations) {
|
|
1187
1355
|
try {
|
|
1188
|
-
this.exec.run(`INSERT
|
|
1189
|
-
(record_id, kind, label, file_id, related_scope, related_id, related_ns,
|
|
1190
|
-
|
|
1356
|
+
this.exec.run(`INSERT INTO associations
|
|
1357
|
+
(record_id, kind, label, file_id, related_scope, related_id, related_ns,
|
|
1358
|
+
related_stack, attachment_record_id)
|
|
1359
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1360
|
+
ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
|
|
1361
|
+
recordId,
|
|
1362
|
+
assoc.kind,
|
|
1363
|
+
assoc.label,
|
|
1364
|
+
...associationKeyColumns(assoc),
|
|
1365
|
+
assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
|
|
1366
|
+
]);
|
|
1191
1367
|
} catch (err) {
|
|
1192
1368
|
if (isForeignKeyViolation(err)) {
|
|
1193
1369
|
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
@@ -1276,8 +1452,8 @@ var SharedSqlRecordAdapter = class {
|
|
|
1276
1452
|
// -------------------------------------------------------
|
|
1277
1453
|
// Records
|
|
1278
1454
|
// -------------------------------------------------------
|
|
1279
|
-
createRecord(record) {
|
|
1280
|
-
return this.record.createRecord(record);
|
|
1455
|
+
createRecord(record, opts) {
|
|
1456
|
+
return this.record.createRecord(record, opts);
|
|
1281
1457
|
}
|
|
1282
1458
|
getRecord(id) {
|
|
1283
1459
|
return this.record.getRecord(id);
|
|
@@ -1316,6 +1492,12 @@ var SharedSqlRecordAdapter = class {
|
|
|
1316
1492
|
return this.record.saveVersion(id, version);
|
|
1317
1493
|
}
|
|
1318
1494
|
// -------------------------------------------------------
|
|
1495
|
+
// Change journal
|
|
1496
|
+
// -------------------------------------------------------
|
|
1497
|
+
getJournal(id, query) {
|
|
1498
|
+
return this.record.getJournal(id, query);
|
|
1499
|
+
}
|
|
1500
|
+
// -------------------------------------------------------
|
|
1319
1501
|
// Types
|
|
1320
1502
|
// -------------------------------------------------------
|
|
1321
1503
|
saveType(type) {
|