@haverstack/record-adapter-sqlite 0.23.0 → 0.25.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, TokenSession } from '@haverstack/core';
|
|
1
|
+
import { StackRecord, JournalOptions, RecordChanges, RecordVersion, ActorOptions, TypeId, StackQuery, QueryResult, FileId, JournalQuery, RecordJournalEntry, StackType, Association, TokenSession } from '@haverstack/core';
|
|
2
2
|
import { StackRecordAdapter, AdapterCapabilities } from '@haverstack/core/adapter';
|
|
3
3
|
import { StackTokenStore, TokenInfo } from '@haverstack/core/wire';
|
|
4
4
|
|
|
@@ -65,47 +65,41 @@ declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
|
|
|
65
65
|
readonly timezone: string | undefined;
|
|
66
66
|
private readonly record;
|
|
67
67
|
protected constructor(exec: SqlExecutor, config: StackConfig);
|
|
68
|
-
createRecord(record: StackRecord): Promise<StackRecord>;
|
|
68
|
+
createRecord(record: StackRecord, opts?: JournalOptions): Promise<StackRecord>;
|
|
69
69
|
getRecord(id: string): Promise<StackRecord | null>;
|
|
70
70
|
mutateRecord(id: string, changes: RecordChanges, opts?: {
|
|
71
71
|
expectedVersion?: number;
|
|
72
72
|
snapshot?: RecordVersion;
|
|
73
|
-
|
|
73
|
+
bumpsVersion?: boolean;
|
|
74
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
74
75
|
deleteRecord(id: string, opts?: {
|
|
75
76
|
hard?: boolean;
|
|
76
77
|
expectedVersion?: number;
|
|
77
78
|
snapshot?: RecordVersion;
|
|
78
|
-
} & ActorOptions): Promise<StackRecord | null>;
|
|
79
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord | null>;
|
|
79
80
|
undeleteRecord(id: string, opts?: {
|
|
80
81
|
expectedVersion?: number;
|
|
81
82
|
snapshot?: RecordVersion;
|
|
82
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
83
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
83
84
|
restoreVersion(id: string, version: number, opts?: {
|
|
84
85
|
expectedVersion?: number;
|
|
85
86
|
snapshot?: RecordVersion;
|
|
86
|
-
} &
|
|
87
|
-
restoreAssociations?: boolean;
|
|
88
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
87
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
89
88
|
commitMigration(id: string, toTypeId: TypeId, content: Record<string, unknown>, opts?: {
|
|
90
89
|
expectedVersion?: number;
|
|
91
90
|
snapshot?: RecordVersion;
|
|
92
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
91
|
+
} & ActorOptions & JournalOptions): Promise<StackRecord>;
|
|
93
92
|
queryRecords(query: StackQuery): Promise<QueryResult>;
|
|
94
93
|
deleteUnreferencedAttachmentRecords(fileId: FileId, metadataTypeIds: TypeId[]): Promise<StackRecord[]>;
|
|
95
94
|
getVersions(id: string): Promise<RecordVersion[]>;
|
|
96
95
|
getVersion(id: string, version: number): Promise<RecordVersion | null>;
|
|
97
96
|
saveVersion(id: string, version: RecordVersion): Promise<void>;
|
|
97
|
+
getJournal(id: string, query?: JournalQuery): Promise<RecordJournalEntry[]>;
|
|
98
98
|
saveType(type: StackType): Promise<void>;
|
|
99
99
|
getType(id: TypeId): Promise<StackType | null>;
|
|
100
100
|
listTypes(): Promise<StackType[]>;
|
|
101
|
-
associate(recordId: string, association: Association, opts?:
|
|
102
|
-
|
|
103
|
-
snapshot?: RecordVersion;
|
|
104
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
105
|
-
dissociate(recordId: string, association: Association, opts?: {
|
|
106
|
-
expectedVersion?: number;
|
|
107
|
-
snapshot?: RecordVersion;
|
|
108
|
-
} & ActorOptions): Promise<StackRecord>;
|
|
101
|
+
associate(recordId: string, association: Association, opts?: JournalOptions): Promise<StackRecord>;
|
|
102
|
+
dissociate(recordId: string, association: Association, opts?: JournalOptions): Promise<StackRecord>;
|
|
109
103
|
abstract flush(): Promise<void>;
|
|
110
104
|
abstract close(): Promise<void>;
|
|
111
105
|
}
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,11 @@ var RECORD_SCHEMA_SQL = `
|
|
|
33
33
|
-- a foreign namespace. All four are in the primary key, so two targets
|
|
34
34
|
-- differing only by namespace are two associations.
|
|
35
35
|
--
|
|
36
|
+
-- attachment_record_id stays out of the primary key: it annotates a
|
|
37
|
+
-- reference rather than naming one, so re-pointing it updates the row an
|
|
38
|
+
-- associate() lands on instead of writing a second reference to the same
|
|
39
|
+
-- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
|
|
40
|
+
--
|
|
36
41
|
-- The primary key leads with record_id, so its implicit index already
|
|
37
42
|
-- serves every by-record read and delete; the indexes below exist only
|
|
38
43
|
-- for the filter directions it cannot answer.
|
|
@@ -45,12 +50,16 @@ var RECORD_SCHEMA_SQL = `
|
|
|
45
50
|
related_id TEXT NOT NULL DEFAULT '',
|
|
46
51
|
related_ns TEXT NOT NULL DEFAULT '',
|
|
47
52
|
related_stack TEXT NOT NULL DEFAULT '',
|
|
53
|
+
attachment_record_id TEXT NOT NULL DEFAULT '',
|
|
48
54
|
PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
|
|
49
55
|
) STRICT;
|
|
50
56
|
|
|
51
57
|
-- parent_id holds the container the record sat in, NULL for the root --
|
|
52
58
|
-- the same encoding records.parent_id uses, since a snapshot states
|
|
53
59
|
-- where the record was rather than an instruction to apply.
|
|
60
|
+
-- No associations column: associate()/dissociate() don't bump version,
|
|
61
|
+
-- so no version ever snapshots the association set. See
|
|
62
|
+
-- docs/spec/versioning.md \xA7 Version history.
|
|
54
63
|
CREATE TABLE IF NOT EXISTS versions (
|
|
55
64
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
56
65
|
version INTEGER NOT NULL,
|
|
@@ -61,11 +70,41 @@ var RECORD_SCHEMA_SQL = `
|
|
|
61
70
|
updated_by TEXT,
|
|
62
71
|
updated_via TEXT,
|
|
63
72
|
parent_id TEXT,
|
|
64
|
-
associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
|
|
65
73
|
permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
|
|
66
74
|
PRIMARY KEY (record_id, version)
|
|
67
75
|
) STRICT;
|
|
68
76
|
|
|
77
|
+
-- The durable half of a change event: which aspects moved, who moved
|
|
78
|
+
-- them, and the association deltas nothing else retains. Envelope-level
|
|
79
|
+
-- by design \u2014 content lives on a versions row, and copying it here
|
|
80
|
+
-- would make the journal the larger of the two stores.
|
|
81
|
+
--
|
|
82
|
+
-- seq is dense from 1 per record and is the only ordering: at is wall
|
|
83
|
+
-- clock, and version stands still across an association change, so
|
|
84
|
+
-- neither orders the log alone. It is allocated inside the appending
|
|
85
|
+
-- write, which is why this table needs none of the collision healing
|
|
86
|
+
-- versions does. See docs/spec/versioning.md \xA7 The change journal.
|
|
87
|
+
CREATE TABLE IF NOT EXISTS journal (
|
|
88
|
+
record_id TEXT NOT NULL REFERENCES records(id),
|
|
89
|
+
seq INTEGER NOT NULL,
|
|
90
|
+
at INTEGER NOT NULL,
|
|
91
|
+
kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted', 'purged')),
|
|
92
|
+
ops TEXT NOT NULL CHECK (json_valid(ops)),
|
|
93
|
+
version INTEGER NOT NULL,
|
|
94
|
+
type_id TEXT NOT NULL,
|
|
95
|
+
parent_id TEXT,
|
|
96
|
+
-- NULL means the entry names no origin (the record did not move);
|
|
97
|
+
-- '' means it moved off the root. parent_id above needs no such
|
|
98
|
+
-- spelling: it is state, where this is a key that may be absent --
|
|
99
|
+
-- the same input/state split parentId follows everywhere.
|
|
100
|
+
previous_parent_id TEXT,
|
|
101
|
+
actor TEXT CHECK (actor IS NULL OR json_valid(actor)),
|
|
102
|
+
associations_added TEXT CHECK (associations_added IS NULL OR json_valid(associations_added)),
|
|
103
|
+
associations_removed TEXT CHECK (associations_removed IS NULL OR json_valid(associations_removed)),
|
|
104
|
+
associations_replaced TEXT CHECK (associations_replaced IS NULL OR json_valid(associations_replaced)),
|
|
105
|
+
PRIMARY KEY (record_id, seq)
|
|
106
|
+
) STRICT;
|
|
107
|
+
|
|
69
108
|
CREATE TABLE IF NOT EXISTS types (
|
|
70
109
|
id TEXT PRIMARY KEY,
|
|
71
110
|
base_id TEXT NOT NULL,
|
|
@@ -581,10 +620,15 @@ var rowToAssociation = (row) => {
|
|
|
581
620
|
return { kind: "tag", label: row.label };
|
|
582
621
|
}
|
|
583
622
|
if (row.kind === "attachment") {
|
|
623
|
+
const attachmentRecordId = row.attachment_record_id;
|
|
584
624
|
return {
|
|
585
625
|
kind: "attachment",
|
|
586
626
|
label: row.label,
|
|
587
|
-
fileId: row.file_id
|
|
627
|
+
fileId: row.file_id,
|
|
628
|
+
// Stored as '' when absent, like every other optional column here —
|
|
629
|
+
// the field is omitted rather than served empty, so a round trip
|
|
630
|
+
// produces the association the caller wrote.
|
|
631
|
+
...attachmentRecordId && { attachmentRecordId }
|
|
588
632
|
};
|
|
589
633
|
}
|
|
590
634
|
return {
|
|
@@ -644,12 +688,37 @@ var rowToVersion = (row) => {
|
|
|
644
688
|
v.updatedVia = row.updated_via;
|
|
645
689
|
if (row.parent_id != null)
|
|
646
690
|
v.parentId = row.parent_id;
|
|
647
|
-
if (row.associations != null)
|
|
648
|
-
v.associations = JSON.parse(row.associations);
|
|
649
691
|
if (row.permissions != null)
|
|
650
692
|
v.permissions = JSON.parse(row.permissions);
|
|
651
693
|
return v;
|
|
652
694
|
};
|
|
695
|
+
var rowToJournalEntry = (row) => {
|
|
696
|
+
const e = {
|
|
697
|
+
seq: row.seq,
|
|
698
|
+
at: fromMs(row.at),
|
|
699
|
+
kind: row.kind,
|
|
700
|
+
ops: JSON.parse(row.ops),
|
|
701
|
+
version: row.version,
|
|
702
|
+
typeId: row.type_id
|
|
703
|
+
};
|
|
704
|
+
if (row.parent_id != null)
|
|
705
|
+
e.parentId = row.parent_id;
|
|
706
|
+
if (row.previous_parent_id != null) {
|
|
707
|
+
e.previousParentId = row.previous_parent_id === "" ? null : row.previous_parent_id;
|
|
708
|
+
}
|
|
709
|
+
if (row.actor != null)
|
|
710
|
+
e.actor = JSON.parse(row.actor);
|
|
711
|
+
if (row.associations_added != null) {
|
|
712
|
+
e.associationsAdded = JSON.parse(row.associations_added);
|
|
713
|
+
}
|
|
714
|
+
if (row.associations_removed != null) {
|
|
715
|
+
e.associationsRemoved = JSON.parse(row.associations_removed);
|
|
716
|
+
}
|
|
717
|
+
if (row.associations_replaced != null) {
|
|
718
|
+
e.associationsReplaced = JSON.parse(row.associations_replaced);
|
|
719
|
+
}
|
|
720
|
+
return e;
|
|
721
|
+
};
|
|
653
722
|
|
|
654
723
|
// ../sqlite-shared/dist/executor.js
|
|
655
724
|
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
@@ -694,7 +763,6 @@ var VERSION_COLUMNS = [
|
|
|
694
763
|
"updated_by",
|
|
695
764
|
"updated_via",
|
|
696
765
|
"parent_id",
|
|
697
|
-
"associations",
|
|
698
766
|
"permissions"
|
|
699
767
|
];
|
|
700
768
|
var versionRowValues = (version) => [
|
|
@@ -705,7 +773,6 @@ var versionRowValues = (version) => [
|
|
|
705
773
|
version.updatedBy ?? null,
|
|
706
774
|
version.updatedVia ?? null,
|
|
707
775
|
version.parentId ?? null,
|
|
708
|
-
version.associations ? JSON.stringify(version.associations) : null,
|
|
709
776
|
version.permissions ? JSON.stringify(version.permissions) : null
|
|
710
777
|
];
|
|
711
778
|
var indexedFieldKinds = (schema) => {
|
|
@@ -809,41 +876,51 @@ var SharedSqlRecordLogic = class {
|
|
|
809
876
|
* A duplicate id hits the `records` PK — mapped to StackConflictError
|
|
810
877
|
* instead of surfacing the raw engine exception, mirroring saveVersion's
|
|
811
878
|
* collision mapping below.
|
|
879
|
+
*
|
|
880
|
+
* One transaction, because a record and the four tables that describe it
|
|
881
|
+
* are one fact. A half-applied create is worse than a failed one: the
|
|
882
|
+
* call raises, so the caller believes nothing landed, while the records
|
|
883
|
+
* row survives to fail their retry on the PK — and the row it leaves is
|
|
884
|
+
* invisible to `filter.search` and mis-ordered by `sort.contentField`
|
|
885
|
+
* until something writes it again.
|
|
812
886
|
*/
|
|
813
|
-
async createRecord(record) {
|
|
814
|
-
|
|
815
|
-
|
|
887
|
+
async createRecord(record, opts = {}) {
|
|
888
|
+
this.exec.transaction(() => {
|
|
889
|
+
try {
|
|
890
|
+
this.exec.run(`INSERT INTO records
|
|
816
891
|
(id, type_id, created_at, updated_at, content, version,
|
|
817
892
|
parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
|
|
818
893
|
deleted_at, unlisted_at, permissions)
|
|
819
894
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
895
|
+
record.id,
|
|
896
|
+
record.typeId,
|
|
897
|
+
toMs(record.createdAt),
|
|
898
|
+
toMs(record.updatedAt),
|
|
899
|
+
JSON.stringify(record.content),
|
|
900
|
+
record.version,
|
|
901
|
+
record.parentId ?? null,
|
|
902
|
+
record.entityId ?? null,
|
|
903
|
+
record.appId ?? null,
|
|
904
|
+
record.principalId ?? null,
|
|
905
|
+
record.updatedBy ?? null,
|
|
906
|
+
record.updatedVia ?? null,
|
|
907
|
+
record.deletedAt ? toMs(record.deletedAt) : null,
|
|
908
|
+
record.unlistedAt ? toMs(record.unlistedAt) : null,
|
|
909
|
+
record.permissions ? JSON.stringify(record.permissions) : null
|
|
910
|
+
]);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
if (isUniqueConstraintViolation(err)) {
|
|
913
|
+
throw new StackConflictError(`Record already exists: "${record.id}"`);
|
|
914
|
+
}
|
|
915
|
+
throw err;
|
|
839
916
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
this.
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
917
|
+
if (record.associations?.length) {
|
|
918
|
+
this.insertAssociations(record.id, record.associations);
|
|
919
|
+
}
|
|
920
|
+
fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
|
|
921
|
+
this.syncContentIndex(record.id, record.typeId, record.content);
|
|
922
|
+
this.appendJournal(record.id, opts.journal);
|
|
923
|
+
});
|
|
847
924
|
return record;
|
|
848
925
|
}
|
|
849
926
|
async getRecord(id) {
|
|
@@ -876,17 +953,40 @@ var SharedSqlRecordLogic = class {
|
|
|
876
953
|
* replaced when it carries one, and the snapshot written alongside — so
|
|
877
954
|
* one call is one version whatever it moved.
|
|
878
955
|
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
956
|
+
* `opts.bumpsVersion: false` is a change set that touches only
|
|
957
|
+
* `associations` — Stack computes this from which ops the set actually
|
|
958
|
+
* moves. Such a write skips the records-table UPDATE (and the snapshot)
|
|
959
|
+
* entirely: nothing on that row changes, so there is nothing to bump and
|
|
960
|
+
* no version to snapshot. `expectedVersion`, if given, is still
|
|
961
|
+
* re-checked synchronously inside the transaction — the CAS guard the
|
|
962
|
+
* bumping path gets from its UPDATE's WHERE clause, restated as a read
|
|
963
|
+
* because there is no UPDATE here to carry it. See
|
|
964
|
+
* docs/spec/versioning.md § Version history.
|
|
965
|
+
*
|
|
966
|
+
* The expectedVersion check for a bumping write is standalone rather than
|
|
967
|
+
* folded into the UPDATE alone, because a content change needs
|
|
968
|
+
* fts.remove() to run *before* the records-table content changes. The
|
|
969
|
+
* guard is still in the UPDATE's WHERE, so a writer that slipped in
|
|
970
|
+
* between the read and the write is caught there rather than overwriting.
|
|
884
971
|
*/
|
|
885
972
|
async mutateRecord(id, changes, opts = {}) {
|
|
886
973
|
const existing = await this.getRecord(id);
|
|
887
974
|
if (!existing)
|
|
888
975
|
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
889
976
|
this.checkExpectedVersion(existing, opts.expectedVersion);
|
|
977
|
+
if (opts.bumpsVersion === false) {
|
|
978
|
+
this.exec.transaction(() => {
|
|
979
|
+
const current = this.readRecord(id);
|
|
980
|
+
if (!current)
|
|
981
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
982
|
+
this.checkExpectedVersion(current, opts.expectedVersion);
|
|
983
|
+
if (changes.associations !== void 0) {
|
|
984
|
+
this.replaceAssociations(id, changes.associations);
|
|
985
|
+
}
|
|
986
|
+
this.appendJournal(id, opts.journal);
|
|
987
|
+
});
|
|
988
|
+
return this.reread(id, "mutateRecord");
|
|
989
|
+
}
|
|
890
990
|
const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
|
|
891
991
|
const sets = [];
|
|
892
992
|
const values = [];
|
|
@@ -920,6 +1020,7 @@ var SharedSqlRecordLogic = class {
|
|
|
920
1020
|
fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
|
|
921
1021
|
this.syncContentIndex(id, existing.typeId, merged);
|
|
922
1022
|
}
|
|
1023
|
+
this.appendJournal(id, opts.journal);
|
|
923
1024
|
});
|
|
924
1025
|
return this.reread(id, "mutateRecord");
|
|
925
1026
|
}
|
|
@@ -932,6 +1033,7 @@ var SharedSqlRecordLogic = class {
|
|
|
932
1033
|
if (opts.snapshot)
|
|
933
1034
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
934
1035
|
this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
|
|
1036
|
+
this.appendJournal(id, opts.journal);
|
|
935
1037
|
});
|
|
936
1038
|
return this.reread(id, "deleteRecord");
|
|
937
1039
|
}
|
|
@@ -955,6 +1057,7 @@ var SharedSqlRecordLogic = class {
|
|
|
955
1057
|
fts5Strategy.remove(this.exec, id);
|
|
956
1058
|
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
957
1059
|
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
1060
|
+
this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
|
|
958
1061
|
this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
|
|
959
1062
|
this.exec.run("DELETE FROM records WHERE id = ?", [id]);
|
|
960
1063
|
return purged;
|
|
@@ -964,6 +1067,7 @@ var SharedSqlRecordLogic = class {
|
|
|
964
1067
|
if (opts.snapshot)
|
|
965
1068
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
966
1069
|
this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
|
|
1070
|
+
this.appendJournal(id, opts.journal);
|
|
967
1071
|
});
|
|
968
1072
|
return this.reread(id, "undelete");
|
|
969
1073
|
}
|
|
@@ -979,9 +1083,7 @@ var SharedSqlRecordLogic = class {
|
|
|
979
1083
|
if (opts.snapshot)
|
|
980
1084
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
981
1085
|
this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
|
|
982
|
-
|
|
983
|
-
if (applied !== void 0)
|
|
984
|
-
this.replaceAssociations(id, applied);
|
|
1086
|
+
this.appendJournal(id, opts.journal);
|
|
985
1087
|
});
|
|
986
1088
|
return this.reread(id, "restoreVersion");
|
|
987
1089
|
}
|
|
@@ -994,6 +1096,7 @@ var SharedSqlRecordLogic = class {
|
|
|
994
1096
|
if (opts.snapshot)
|
|
995
1097
|
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
996
1098
|
this.rewriteContent(id, toTypeId, content, opts);
|
|
1099
|
+
this.appendJournal(id, opts.journal);
|
|
997
1100
|
});
|
|
998
1101
|
return this.reread(id, "commitMigration");
|
|
999
1102
|
}
|
|
@@ -1136,6 +1239,59 @@ var SharedSqlRecordLogic = class {
|
|
|
1136
1239
|
}
|
|
1137
1240
|
}
|
|
1138
1241
|
// -------------------------------------------------------
|
|
1242
|
+
// Change journal
|
|
1243
|
+
// -------------------------------------------------------
|
|
1244
|
+
/**
|
|
1245
|
+
* Append one entry, stamping the record-derived half off the row as it
|
|
1246
|
+
* now stands — so every caller must already sit inside the same
|
|
1247
|
+
* transaction as the write it describes, after that write has landed.
|
|
1248
|
+
*
|
|
1249
|
+
* `seq` is allocated here, from the log's own max, rather than computed
|
|
1250
|
+
* by the caller. That is what spares this table the collision healing
|
|
1251
|
+
* versions needs: nothing outside this statement ever holds a seq it
|
|
1252
|
+
* expects to be free. See docs/spec/versioning.md § The change journal.
|
|
1253
|
+
*/
|
|
1254
|
+
appendJournal(recordId, entry) {
|
|
1255
|
+
if (!entry)
|
|
1256
|
+
return;
|
|
1257
|
+
const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
|
|
1258
|
+
if (!row)
|
|
1259
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1260
|
+
this.exec.run(`INSERT INTO journal
|
|
1261
|
+
(record_id, seq, at, kind, ops, version, type_id, parent_id,
|
|
1262
|
+
previous_parent_id, actor, associations_added, associations_removed,
|
|
1263
|
+
associations_replaced)
|
|
1264
|
+
VALUES (
|
|
1265
|
+
?,
|
|
1266
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
|
|
1267
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1268
|
+
recordId,
|
|
1269
|
+
recordId,
|
|
1270
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
1271
|
+
entry.kind,
|
|
1272
|
+
JSON.stringify(entry.ops),
|
|
1273
|
+
row.version,
|
|
1274
|
+
row.type_id,
|
|
1275
|
+
row.parent_id,
|
|
1276
|
+
// '' is the root; NULL is an entry that names no origin at all.
|
|
1277
|
+
entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
|
|
1278
|
+
entry.actor ? JSON.stringify(entry.actor) : null,
|
|
1279
|
+
entry.associationsAdded?.length ? JSON.stringify(entry.associationsAdded) : null,
|
|
1280
|
+
entry.associationsRemoved?.length ? JSON.stringify(entry.associationsRemoved) : null,
|
|
1281
|
+
entry.associationsReplaced?.length ? JSON.stringify(entry.associationsReplaced) : null
|
|
1282
|
+
]);
|
|
1283
|
+
}
|
|
1284
|
+
async getJournal(id, query = {}) {
|
|
1285
|
+
const conditions = ["record_id = ?"];
|
|
1286
|
+
const values = [id];
|
|
1287
|
+
if (query.sinceSeq !== void 0) {
|
|
1288
|
+
conditions.push("seq > ?");
|
|
1289
|
+
values.push(query.sinceSeq);
|
|
1290
|
+
}
|
|
1291
|
+
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);
|
|
1292
|
+
return rows.map(rowToJournalEntry);
|
|
1293
|
+
}
|
|
1294
|
+
// -------------------------------------------------------
|
|
1139
1295
|
// Types
|
|
1140
1296
|
// -------------------------------------------------------
|
|
1141
1297
|
async saveType(type) {
|
|
@@ -1165,20 +1321,27 @@ var SharedSqlRecordLogic = class {
|
|
|
1165
1321
|
// -------------------------------------------------------
|
|
1166
1322
|
// Associations
|
|
1167
1323
|
// -------------------------------------------------------
|
|
1324
|
+
/**
|
|
1325
|
+
* Never bumps `version`/`updatedAt` and never snapshots — a set-add
|
|
1326
|
+
* composes correctly regardless of write order, so it needs neither OCC
|
|
1327
|
+
* nor rollback history. The records row is untouched entirely; only the
|
|
1328
|
+
* associations table changes. See docs/spec/versioning.md § Version
|
|
1329
|
+
* history.
|
|
1330
|
+
*/
|
|
1168
1331
|
async associate(recordId, association, opts = {}) {
|
|
1169
1332
|
this.exec.transaction(() => {
|
|
1170
|
-
if (
|
|
1171
|
-
|
|
1172
|
-
this.versionedUpdate(recordId, [], [], opts);
|
|
1333
|
+
if (!this.readRecord(recordId))
|
|
1334
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1173
1335
|
this.insertAssociations(recordId, [association]);
|
|
1336
|
+
this.appendJournal(recordId, opts.journal);
|
|
1174
1337
|
});
|
|
1175
1338
|
return this.reread(recordId, "associate");
|
|
1176
1339
|
}
|
|
1340
|
+
/** Never bumps `version`/`updatedAt` — see associate(). */
|
|
1177
1341
|
async dissociate(recordId, association, opts = {}) {
|
|
1178
1342
|
this.exec.transaction(() => {
|
|
1179
|
-
if (
|
|
1180
|
-
|
|
1181
|
-
this.versionedUpdate(recordId, [], [], opts);
|
|
1343
|
+
if (!this.readRecord(recordId))
|
|
1344
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
1182
1345
|
this.exec.run(`DELETE FROM associations
|
|
1183
1346
|
WHERE record_id = ?
|
|
1184
1347
|
AND kind = ?
|
|
@@ -1188,6 +1351,7 @@ var SharedSqlRecordLogic = class {
|
|
|
1188
1351
|
AND related_id = ?
|
|
1189
1352
|
AND related_ns = ?
|
|
1190
1353
|
AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
1354
|
+
this.appendJournal(recordId, opts.journal);
|
|
1191
1355
|
});
|
|
1192
1356
|
return this.reread(recordId, "dissociate");
|
|
1193
1357
|
}
|
|
@@ -1203,6 +1367,10 @@ var SharedSqlRecordLogic = class {
|
|
|
1203
1367
|
this.insertAssociations(recordId, associations);
|
|
1204
1368
|
}
|
|
1205
1369
|
/**
|
|
1370
|
+
* Upserts on association identity: the primary key decides which row a
|
|
1371
|
+
* write lands on, and `attachment_record_id` — outside it — is whatever
|
|
1372
|
+
* the incoming association says, so re-pointing one costs no second row.
|
|
1373
|
+
*
|
|
1206
1374
|
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
1207
1375
|
* association against a record that doesn't exist throws — mapped
|
|
1208
1376
|
* here to StackNotFoundError so associate() on a nonexistent record
|
|
@@ -1211,9 +1379,17 @@ var SharedSqlRecordLogic = class {
|
|
|
1211
1379
|
insertAssociations(recordId, associations) {
|
|
1212
1380
|
for (const assoc of associations) {
|
|
1213
1381
|
try {
|
|
1214
|
-
this.exec.run(`INSERT
|
|
1215
|
-
(record_id, kind, label, file_id, related_scope, related_id, related_ns,
|
|
1216
|
-
|
|
1382
|
+
this.exec.run(`INSERT INTO associations
|
|
1383
|
+
(record_id, kind, label, file_id, related_scope, related_id, related_ns,
|
|
1384
|
+
related_stack, attachment_record_id)
|
|
1385
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1386
|
+
ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
|
|
1387
|
+
recordId,
|
|
1388
|
+
assoc.kind,
|
|
1389
|
+
assoc.label,
|
|
1390
|
+
...associationKeyColumns(assoc),
|
|
1391
|
+
assoc.kind === "attachment" ? assoc.attachmentRecordId ?? "" : ""
|
|
1392
|
+
]);
|
|
1217
1393
|
} catch (err) {
|
|
1218
1394
|
if (isForeignKeyViolation(err)) {
|
|
1219
1395
|
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
@@ -1302,8 +1478,8 @@ var SharedSqlRecordAdapter = class {
|
|
|
1302
1478
|
// -------------------------------------------------------
|
|
1303
1479
|
// Records
|
|
1304
1480
|
// -------------------------------------------------------
|
|
1305
|
-
createRecord(record) {
|
|
1306
|
-
return this.record.createRecord(record);
|
|
1481
|
+
createRecord(record, opts) {
|
|
1482
|
+
return this.record.createRecord(record, opts);
|
|
1307
1483
|
}
|
|
1308
1484
|
getRecord(id) {
|
|
1309
1485
|
return this.record.getRecord(id);
|
|
@@ -1342,6 +1518,12 @@ var SharedSqlRecordAdapter = class {
|
|
|
1342
1518
|
return this.record.saveVersion(id, version);
|
|
1343
1519
|
}
|
|
1344
1520
|
// -------------------------------------------------------
|
|
1521
|
+
// Change journal
|
|
1522
|
+
// -------------------------------------------------------
|
|
1523
|
+
getJournal(id, query) {
|
|
1524
|
+
return this.record.getJournal(id, query);
|
|
1525
|
+
}
|
|
1526
|
+
// -------------------------------------------------------
|
|
1345
1527
|
// Types
|
|
1346
1528
|
// -------------------------------------------------------
|
|
1347
1529
|
saveType(type) {
|