@haverstack/record-adapter-sqlite 0.35.0 → 0.37.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 +21 -16
- package/dist/index.js +210 -160
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { StackCapabilities, StackRecord, TypeId, QueryResult, FileId, BaseId, VersionsQuery, RecordVersion, JournalQuery, RecordJournalEntry, StackType, AssociationEdit, DataAssociation, AuthorityAssociation, Actor } from '@haverstack/core';
|
|
2
|
-
import { StackRecordAdapter, JournalOptions, MutateOptions, AspectWriteOptions, AdapterQuery, Reach, ElementWriteOptions } from '@haverstack/core/adapter';
|
|
1
|
+
import { StackCapabilities, ConfigContent, StackRecord, TypeId, QueryResult, FileId, BaseId, VersionsQuery, RecordVersion, JournalQuery, RecordJournalEntry, StackType, AssociationEdit, DataAssociation, AuthorityAssociation, Actor } from '@haverstack/core';
|
|
2
|
+
import { StackRecordAdapter, StoreWriters, JournalOptions, RecordHistory, MutateOptions, AspectWriteOptions, AdapterQuery, Reach, ElementWriteOptions, StoreSeed } from '@haverstack/core/adapter';
|
|
3
3
|
import { StackTokenStore, TokenSession, TokenInfo } from '@haverstack/core/wire';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -32,11 +32,10 @@ interface SqlExecutor {
|
|
|
32
32
|
transaction<T>(fn: () => T): T;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
type
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
};
|
|
35
|
+
/** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */
|
|
36
|
+
type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive';
|
|
37
|
+
/** A plain DID, or a lazy provider called only when a store is actually created. */
|
|
38
|
+
type OwnerEntityIdInput = string | (() => string | Promise<string>);
|
|
40
39
|
|
|
41
40
|
/**
|
|
42
41
|
* The engine-independent half of a SQLite-backed StackRecordAdapter: the
|
|
@@ -58,10 +57,15 @@ type StackConfig = {
|
|
|
58
57
|
declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
|
|
59
58
|
readonly capabilities: StackCapabilities;
|
|
60
59
|
readonly ownerEntityId: string;
|
|
61
|
-
|
|
60
|
+
/** Each engine holds its database for one process; see docs/spec/adapters.md § Concurrency & storage ownership. */
|
|
61
|
+
readonly writers: StoreWriters;
|
|
62
62
|
private readonly record;
|
|
63
|
-
|
|
63
|
+
/** Moved by every write method; see head(). */
|
|
64
|
+
private readonly written;
|
|
65
|
+
protected constructor(exec: SqlExecutor, config: ConfigContent);
|
|
66
|
+
head(): Promise<string>;
|
|
64
67
|
createRecord(record: StackRecord, opts?: JournalOptions): Promise<StackRecord>;
|
|
68
|
+
importRecord(record: StackRecord, history: RecordHistory): Promise<StackRecord>;
|
|
65
69
|
getRecord(id: string): Promise<StackRecord | null>;
|
|
66
70
|
patchContent(id: string, patch: Record<string, unknown | null>, opts?: MutateOptions): Promise<StackRecord>;
|
|
67
71
|
setParent(id: string, parentId: string | null, opts?: AspectWriteOptions): Promise<StackRecord>;
|
|
@@ -77,7 +81,6 @@ declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
|
|
|
77
81
|
deleteUnreferencedAttachmentRecords(fileId: FileId, metadataBaseId: BaseId): Promise<StackRecord[]>;
|
|
78
82
|
getVersions(id: string, query?: VersionsQuery): Promise<RecordVersion[]>;
|
|
79
83
|
getVersion(id: string, version: number): Promise<RecordVersion | null>;
|
|
80
|
-
saveVersion(id: string, version: RecordVersion): Promise<void>;
|
|
81
84
|
getJournal(id: string, query?: JournalQuery): Promise<RecordJournalEntry[]>;
|
|
82
85
|
saveType(type: StackType): Promise<void>;
|
|
83
86
|
getType(id: TypeId): Promise<StackType | null>;
|
|
@@ -159,15 +162,9 @@ declare class NativeTokenStore implements StackTokenStore {
|
|
|
159
162
|
* spread-args run/get/all calls to it).
|
|
160
163
|
*/
|
|
161
164
|
|
|
162
|
-
/** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */
|
|
163
|
-
type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive';
|
|
164
|
-
/** A plain DID, or a lazy provider called only when a store is actually created. */
|
|
165
|
-
type OwnerEntityIdInput = string | (() => string | Promise<string>);
|
|
166
165
|
type NativeSQLiteRecordAdapterOpenOptions = {
|
|
167
166
|
/** Absolute path to the .db file. */
|
|
168
167
|
path: string;
|
|
169
|
-
/** IANA timezone string e.g. "America/New_York". Consulted only when a store is created. No default. */
|
|
170
|
-
timezone?: string;
|
|
171
168
|
/**
|
|
172
169
|
* Open even if a lock file from another live process is present.
|
|
173
170
|
* Only needed if that process is gone but its PID was reused by
|
|
@@ -180,6 +177,7 @@ type NativeSQLiteRecordAdapterOpenOptions = {
|
|
|
180
177
|
create?: 'never';
|
|
181
178
|
/** Checked against the existing store's owner; a mismatch throws OwnerMismatchError. */
|
|
182
179
|
ownerEntityId?: string;
|
|
180
|
+
seed?: never;
|
|
183
181
|
} | {
|
|
184
182
|
/** `'ifMissing'` opens the store if present and creates it if not; `'exclusive'` creates it and fails if present. */
|
|
185
183
|
create: 'ifMissing' | 'exclusive';
|
|
@@ -188,6 +186,13 @@ type NativeSQLiteRecordAdapterOpenOptions = {
|
|
|
188
186
|
* existing store's owner; a lazy provider is never called just to compare.
|
|
189
187
|
*/
|
|
190
188
|
ownerEntityId: OwnerEntityIdInput;
|
|
189
|
+
seed?: never;
|
|
190
|
+
} | {
|
|
191
|
+
/** A seed only creates, as `'exclusive'` does: an existing store refuses it. */
|
|
192
|
+
create?: never;
|
|
193
|
+
/** The `_config` record and history the store is created holding: a copy's source's. */
|
|
194
|
+
seed: StoreSeed;
|
|
195
|
+
ownerEntityId?: never;
|
|
191
196
|
});
|
|
192
197
|
declare class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter {
|
|
193
198
|
private readonly path;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
1
|
+
import { existsSync, rmSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
|
2
|
+
import { trackWrites, OwnerMismatchError, newStoreSeed, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, FormatMismatchError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
|
|
2
3
|
import { StackConflictError, applyMergePatch, StackNotFoundError, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
|
|
3
|
-
import { OwnerMismatchError, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
|
|
4
4
|
import { randomBytes, createHash } from 'crypto';
|
|
5
5
|
|
|
6
6
|
// src/node-sqlite.ts
|
|
@@ -9,8 +9,16 @@ var { DatabaseSync } = process.getBuiltinModule(
|
|
|
9
9
|
);
|
|
10
10
|
|
|
11
11
|
// ../sqlite-shared/dist/schema.js
|
|
12
|
+
var FORMAT_VERSION = 1;
|
|
12
13
|
var RECORD_SCHEMA_SQL = `
|
|
13
|
-
|
|
14
|
+
-- Which layout this store is, in its one row, so an export names the one
|
|
15
|
+
-- a reader holds. See docs/spec/portability.md \xA7 The portable format.
|
|
16
|
+
CREATE TABLE format (
|
|
17
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
18
|
+
version INTEGER NOT NULL
|
|
19
|
+
) STRICT;
|
|
20
|
+
|
|
21
|
+
CREATE TABLE records (
|
|
14
22
|
id TEXT PRIMARY KEY,
|
|
15
23
|
type_id TEXT NOT NULL,
|
|
16
24
|
created_at INTEGER NOT NULL,
|
|
@@ -30,7 +38,7 @@ var RECORD_SCHEMA_SQL = `
|
|
|
30
38
|
-- A record's data associations: tags, attachments and relationships.
|
|
31
39
|
-- Its permissions are in their own table below, so no predicate over this
|
|
32
40
|
-- one can match a grantee as a relationship target.
|
|
33
|
-
CREATE TABLE
|
|
41
|
+
CREATE TABLE associations (
|
|
34
42
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
35
43
|
kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
|
|
36
44
|
label TEXT NOT NULL,
|
|
@@ -56,7 +64,7 @@ var RECORD_SCHEMA_SQL = `
|
|
|
56
64
|
-- grantee; a group grantee's role is in the key, because member and
|
|
57
65
|
-- admin name two different sets of people.
|
|
58
66
|
-- See docs/spec/access-control.md \xA7 Record-level permissions.
|
|
59
|
-
CREATE TABLE
|
|
67
|
+
CREATE TABLE permissions (
|
|
60
68
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
61
69
|
kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
|
|
62
70
|
label TEXT NOT NULL CHECK (label IN ('read', 'write')),
|
|
@@ -70,7 +78,7 @@ var RECORD_SCHEMA_SQL = `
|
|
|
70
78
|
-- associations column: none of those aspects bumps version, so no
|
|
71
79
|
-- version ever snapshots one. See
|
|
72
80
|
-- docs/spec/versioning.md \xA7 Version history.
|
|
73
|
-
CREATE TABLE
|
|
81
|
+
CREATE TABLE versions (
|
|
74
82
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
75
83
|
version INTEGER NOT NULL,
|
|
76
84
|
type_id TEXT NOT NULL,
|
|
@@ -88,7 +96,7 @@ var RECORD_SCHEMA_SQL = `
|
|
|
88
96
|
-- by design \u2014 content lives on a versions row, and copying it here
|
|
89
97
|
-- would make the journal the larger of the two stores.
|
|
90
98
|
-- See docs/spec/journal.md.
|
|
91
|
-
CREATE TABLE
|
|
99
|
+
CREATE TABLE journal (
|
|
92
100
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
93
101
|
seq INTEGER NOT NULL,
|
|
94
102
|
at INTEGER NOT NULL,
|
|
@@ -112,7 +120,7 @@ var RECORD_SCHEMA_SQL = `
|
|
|
112
120
|
PRIMARY KEY (record_id, seq)
|
|
113
121
|
) STRICT;
|
|
114
122
|
|
|
115
|
-
CREATE TABLE
|
|
123
|
+
CREATE TABLE types (
|
|
116
124
|
id TEXT PRIMARY KEY,
|
|
117
125
|
base_id TEXT NOT NULL,
|
|
118
126
|
version INTEGER NOT NULL,
|
|
@@ -130,7 +138,7 @@ var RECORD_SCHEMA_SQL = `
|
|
|
130
138
|
-- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
|
|
131
139
|
-- split as "num_value IS NULL", but non-null, so one index can carry
|
|
132
140
|
-- the whole ordering. file_id is set only for a 'file-ref' field.
|
|
133
|
-
CREATE TABLE
|
|
141
|
+
CREATE TABLE content_index (
|
|
134
142
|
record_id TEXT NOT NULL REFERENCES records(id),
|
|
135
143
|
field TEXT NOT NULL,
|
|
136
144
|
value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
|
|
@@ -146,35 +154,35 @@ var RECORD_SCHEMA_SQL = `
|
|
|
146
154
|
-- leaves the planner sorting every match before it can honor LIMIT.
|
|
147
155
|
-- deleted_at and unlisted_at get none: an index on a column every query
|
|
148
156
|
-- tests IS NULL only lures the planner off the one answering ORDER BY.
|
|
149
|
-
CREATE INDEX
|
|
150
|
-
CREATE INDEX
|
|
151
|
-
CREATE INDEX
|
|
152
|
-
CREATE INDEX
|
|
153
|
-
CREATE INDEX
|
|
154
|
-
CREATE INDEX
|
|
155
|
-
CREATE INDEX
|
|
156
|
-
CREATE INDEX
|
|
157
|
+
CREATE INDEX idx_records_type_id ON records(type_id, created_at, id);
|
|
158
|
+
CREATE INDEX idx_records_parent_id ON records(parent_id, created_at, id);
|
|
159
|
+
CREATE INDEX idx_records_entity_id ON records(entity_id, created_at, id);
|
|
160
|
+
CREATE INDEX idx_records_app_id ON records(app_id, created_at, id);
|
|
161
|
+
CREATE INDEX idx_records_principal_id ON records(principal_id, created_at, id);
|
|
162
|
+
CREATE INDEX idx_records_created_at ON records(created_at, id);
|
|
163
|
+
CREATE INDEX idx_records_updated_at ON records(updated_at, id);
|
|
164
|
+
CREATE INDEX idx_records_version ON records(version, id);
|
|
157
165
|
|
|
158
166
|
-- Each association index ends with record_id so the semi-joins in
|
|
159
167
|
-- query.ts read the matching record ids out of the index alone.
|
|
160
168
|
-- idx_assoc_related carries related_ns ahead of related_id because a
|
|
161
169
|
-- record- or entity-scoped target always stores '' there, which
|
|
162
170
|
-- buildQueryPlan states explicitly to keep the prefix contiguous.
|
|
163
|
-
CREATE INDEX
|
|
164
|
-
CREATE INDEX
|
|
165
|
-
CREATE INDEX
|
|
166
|
-
CREATE INDEX
|
|
171
|
+
CREATE INDEX idx_assoc_kind_label ON associations(kind, label, record_id);
|
|
172
|
+
CREATE INDEX idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
|
|
173
|
+
CREATE INDEX idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
|
|
174
|
+
CREATE INDEX idx_types_base_id ON types(base_id, version);
|
|
167
175
|
|
|
168
176
|
-- The permissions key with record_id moved last, so a scoped query reads
|
|
169
177
|
-- the records carrying an element straight out of the index.
|
|
170
|
-
CREATE INDEX
|
|
178
|
+
CREATE INDEX idx_permissions_element
|
|
171
179
|
ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
|
|
172
180
|
|
|
173
181
|
-- The whole content ordering, in index order, for one field at a time:
|
|
174
182
|
-- a content-sorted page is an ordered walk of a slice of this index.
|
|
175
|
-
CREATE INDEX
|
|
183
|
+
CREATE INDEX idx_content_index_sort
|
|
176
184
|
ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
|
|
177
|
-
CREATE INDEX
|
|
185
|
+
CREATE INDEX idx_content_index_file
|
|
178
186
|
ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
|
|
179
187
|
`;
|
|
180
188
|
var TOKENS_SCHEMA_SQL = `
|
|
@@ -189,7 +197,7 @@ var TOKENS_SCHEMA_SQL = `
|
|
|
189
197
|
) STRICT;
|
|
190
198
|
`;
|
|
191
199
|
var FTS5_SCHEMA_SQL = `
|
|
192
|
-
CREATE VIRTUAL TABLE
|
|
200
|
+
CREATE VIRTUAL TABLE records_fts USING fts5(
|
|
193
201
|
content,
|
|
194
202
|
content='records',
|
|
195
203
|
content_rowid='rowid',
|
|
@@ -198,11 +206,10 @@ var FTS5_SCHEMA_SQL = `
|
|
|
198
206
|
`;
|
|
199
207
|
var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
|
|
200
208
|
var PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`;
|
|
201
|
-
var
|
|
209
|
+
var applyConnectionPragmas = (exec, opts) => {
|
|
202
210
|
exec.exec(PRAGMA_FOREIGN_KEYS_ON);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
exec.exec(FTS5_SCHEMA_SQL);
|
|
211
|
+
if (opts.wal)
|
|
212
|
+
exec.exec(PRAGMA_JOURNAL_MODE_WAL);
|
|
206
213
|
};
|
|
207
214
|
|
|
208
215
|
// ../sqlite-shared/dist/fts5.js
|
|
@@ -288,28 +295,6 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
|
|
|
288
295
|
// ../sqlite-shared/dist/executor.js
|
|
289
296
|
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
290
297
|
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
291
|
-
|
|
292
|
-
// ../sqlite-shared/dist/config.js
|
|
293
|
-
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
294
|
-
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
295
|
-
const now = Date.now();
|
|
296
|
-
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
297
|
-
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
298
|
-
return { entityId, timezone };
|
|
299
|
-
};
|
|
300
|
-
var tryReadStackConfig = (exec) => {
|
|
301
|
-
const row = exec.get(CONFIG_ROW_SQL);
|
|
302
|
-
if (!row)
|
|
303
|
-
return null;
|
|
304
|
-
const content = JSON.parse(row.content);
|
|
305
|
-
return { entityId: content.entityId, timezone: content.timezone };
|
|
306
|
-
};
|
|
307
|
-
var readStackConfig = (exec) => {
|
|
308
|
-
const config = tryReadStackConfig(exec);
|
|
309
|
-
if (!config)
|
|
310
|
-
throw new Error("Stack database is missing its config record.");
|
|
311
|
-
return config;
|
|
312
|
-
};
|
|
313
298
|
var toBase64 = (text) => {
|
|
314
299
|
const bytes = new TextEncoder().encode(text);
|
|
315
300
|
let binary = "";
|
|
@@ -979,6 +964,35 @@ var versionRowValues = (version) => [
|
|
|
979
964
|
version.updatedBy?.subjectId ?? null,
|
|
980
965
|
version.updatedBy?.principalId ?? null
|
|
981
966
|
];
|
|
967
|
+
var JOURNAL_COLUMNS = [
|
|
968
|
+
"record_id",
|
|
969
|
+
"seq",
|
|
970
|
+
"at",
|
|
971
|
+
"kind",
|
|
972
|
+
"ops",
|
|
973
|
+
"previous_parent_id",
|
|
974
|
+
"actor",
|
|
975
|
+
"associations",
|
|
976
|
+
"version",
|
|
977
|
+
"type_id",
|
|
978
|
+
"parent_id"
|
|
979
|
+
];
|
|
980
|
+
var INSERT_JOURNAL_SQL = `INSERT INTO journal (${JOURNAL_COLUMNS.join(", ")})
|
|
981
|
+
VALUES (${JOURNAL_COLUMNS.map(() => "?").join(", ")})`;
|
|
982
|
+
var journalValues = (recordId, seq, at, entry, row) => [
|
|
983
|
+
recordId,
|
|
984
|
+
seq,
|
|
985
|
+
toMs(at),
|
|
986
|
+
entry.kind,
|
|
987
|
+
JSON.stringify(entry.ops),
|
|
988
|
+
// '' is the root; NULL is an entry that names no origin at all.
|
|
989
|
+
entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
|
|
990
|
+
entry.actor ? JSON.stringify(entry.actor) : null,
|
|
991
|
+
entry.associations?.length ? JSON.stringify(entry.associations) : null,
|
|
992
|
+
row.version,
|
|
993
|
+
row.typeId,
|
|
994
|
+
row.parentId
|
|
995
|
+
];
|
|
982
996
|
var SharedSqlRecordLogic = class {
|
|
983
997
|
exec;
|
|
984
998
|
searchIndex;
|
|
@@ -1074,42 +1088,71 @@ var SharedSqlRecordLogic = class {
|
|
|
1074
1088
|
*/
|
|
1075
1089
|
async createRecord(record, opts = {}) {
|
|
1076
1090
|
this.exec.transaction(() => {
|
|
1077
|
-
|
|
1078
|
-
|
|
1091
|
+
this.insertRecord(record);
|
|
1092
|
+
this.appendJournal(record.id, opts.journal);
|
|
1093
|
+
});
|
|
1094
|
+
return record;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* createRecord() carrying the record's history as recorded: its version
|
|
1098
|
+
* rows and journal entries, `seq` and `at` included, in the same
|
|
1099
|
+
* transaction. Appends no entry of its own. See
|
|
1100
|
+
* docs/spec/portability.md § Writing history.
|
|
1101
|
+
*/
|
|
1102
|
+
async importRecord(record, history) {
|
|
1103
|
+
this.exec.transaction(() => this.writeImported(record, history));
|
|
1104
|
+
return record;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* importRecord()'s write, for a caller already inside a transaction:
|
|
1108
|
+
* writeNewStore() writes a store's `_config` seed through it, so the one
|
|
1109
|
+
* Record a store starts with lands as every other Record does.
|
|
1110
|
+
*/
|
|
1111
|
+
writeImported(record, history) {
|
|
1112
|
+
this.insertRecord(record);
|
|
1113
|
+
for (const version of history.versions)
|
|
1114
|
+
this.insertVersionRow(record.id, version);
|
|
1115
|
+
for (const entry of history.journal)
|
|
1116
|
+
this.insertJournalRow(record.id, entry);
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* The record row and every table describing it, for a caller already
|
|
1120
|
+
* inside a transaction. A duplicate id maps to StackConflictError.
|
|
1121
|
+
*/
|
|
1122
|
+
insertRecord(record) {
|
|
1123
|
+
try {
|
|
1124
|
+
this.exec.run(`INSERT INTO records
|
|
1079
1125
|
(id, type_id, created_at, updated_at, content, version,
|
|
1080
1126
|
parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
|
|
1081
1127
|
deleted_at, unlisted_at)
|
|
1082
1128
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
}
|
|
1102
|
-
throw err;
|
|
1129
|
+
record.id,
|
|
1130
|
+
record.typeId,
|
|
1131
|
+
toMs(record.createdAt),
|
|
1132
|
+
toMs(record.updatedAt),
|
|
1133
|
+
JSON.stringify(record.content),
|
|
1134
|
+
record.version,
|
|
1135
|
+
record.parentId ?? null,
|
|
1136
|
+
record.createdBy?.subjectId ?? null,
|
|
1137
|
+
record.appId ?? null,
|
|
1138
|
+
record.createdBy?.principalId ?? null,
|
|
1139
|
+
record.updatedBy?.subjectId ?? null,
|
|
1140
|
+
record.updatedBy?.principalId ?? null,
|
|
1141
|
+
record.deletedAt ? toMs(record.deletedAt) : null,
|
|
1142
|
+
record.unlistedAt ? toMs(record.unlistedAt) : null
|
|
1143
|
+
]);
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
if (isUniqueConstraintViolation(err)) {
|
|
1146
|
+
throw new StackConflictError(`Record already exists: "${record.id}"`);
|
|
1103
1147
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
this.
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
return record;
|
|
1148
|
+
throw err;
|
|
1149
|
+
}
|
|
1150
|
+
if (record.associations?.length)
|
|
1151
|
+
this.insertAssociations(record.id, record.associations);
|
|
1152
|
+
if (record.permissions?.length)
|
|
1153
|
+
this.insertPermissions(record.id, record.permissions);
|
|
1154
|
+
this.searchIndex.insert(record.id, JSON.stringify(record.content));
|
|
1155
|
+
this.contentIndex.sync(record.id, record.typeId, record.content);
|
|
1113
1156
|
}
|
|
1114
1157
|
async getRecord(id) {
|
|
1115
1158
|
return this.readRecord(id);
|
|
@@ -1334,7 +1377,7 @@ var SharedSqlRecordLogic = class {
|
|
|
1334
1377
|
VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
|
|
1335
1378
|
} catch (err) {
|
|
1336
1379
|
if (isUniqueConstraintViolation(err)) {
|
|
1337
|
-
throw new StackConflictError(`Version ${version.version} already exists for record "${id}"
|
|
1380
|
+
throw new StackConflictError(`Version ${version.version} already exists for record "${id}"`);
|
|
1338
1381
|
}
|
|
1339
1382
|
throw err;
|
|
1340
1383
|
}
|
|
@@ -1350,14 +1393,6 @@ var SharedSqlRecordLogic = class {
|
|
|
1350
1393
|
SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
|
|
1351
1394
|
WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
|
|
1352
1395
|
}
|
|
1353
|
-
/**
|
|
1354
|
-
* Standalone snapshot write for tooling and tests — loud on any
|
|
1355
|
-
* collision, since nothing here is about to bump the version. Mutating
|
|
1356
|
-
* methods take a `snapshot` option instead; see snapshotBeforeMutation.
|
|
1357
|
-
*/
|
|
1358
|
-
async saveVersion(id, version) {
|
|
1359
|
-
this.insertVersionRow(id, version);
|
|
1360
|
-
}
|
|
1361
1396
|
/**
|
|
1362
1397
|
* The snapshot half of a mutating method's atomic snapshot-then-mutate
|
|
1363
1398
|
* transaction. A collision is a racing-writer conflict, except an orphan
|
|
@@ -1371,8 +1406,9 @@ var SharedSqlRecordLogic = class {
|
|
|
1371
1406
|
} catch (err) {
|
|
1372
1407
|
if (!(err instanceof StackConflictError))
|
|
1373
1408
|
throw err;
|
|
1374
|
-
if (this.currentVersion(id) !== version.version)
|
|
1375
|
-
throw
|
|
1409
|
+
if (this.currentVersion(id) !== version.version) {
|
|
1410
|
+
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.`);
|
|
1411
|
+
}
|
|
1376
1412
|
this.overwriteVersionRow(id, version);
|
|
1377
1413
|
}
|
|
1378
1414
|
}
|
|
@@ -1381,9 +1417,9 @@ var SharedSqlRecordLogic = class {
|
|
|
1381
1417
|
// -------------------------------------------------------
|
|
1382
1418
|
/**
|
|
1383
1419
|
* Append one entry, stamped from the row as it now stands, so callers run
|
|
1384
|
-
* this inside their write's transaction, after the write. `seq` is
|
|
1385
|
-
*
|
|
1386
|
-
* to be free. See docs/spec/journal.md § Ordering.
|
|
1420
|
+
* this inside their write's transaction, after the write. `seq` is read
|
|
1421
|
+
* from the log's maximum in that same transaction, so no caller ever holds
|
|
1422
|
+
* one it expects to be free. See docs/spec/journal.md § Ordering.
|
|
1387
1423
|
*/
|
|
1388
1424
|
appendJournal(recordId, entry) {
|
|
1389
1425
|
if (!entry)
|
|
@@ -1391,26 +1427,20 @@ var SharedSqlRecordLogic = class {
|
|
|
1391
1427
|
const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
|
|
1392
1428
|
if (!row)
|
|
1393
1429
|
throw recordNotFound(recordId);
|
|
1394
|
-
this.exec.
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
entry.
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
row.parent_id,
|
|
1409
|
-
// '' is the root; NULL is an entry that names no origin at all.
|
|
1410
|
-
entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
|
|
1411
|
-
entry.actor ? JSON.stringify(entry.actor) : null,
|
|
1412
|
-
entry.associations?.length ? JSON.stringify(entry.associations) : null
|
|
1413
|
-
]);
|
|
1430
|
+
const { seq } = this.exec.get("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM journal WHERE record_id = ?", [recordId]);
|
|
1431
|
+
this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, seq, /* @__PURE__ */ new Date(), entry, {
|
|
1432
|
+
version: row.version,
|
|
1433
|
+
typeId: row.type_id,
|
|
1434
|
+
parentId: row.parent_id
|
|
1435
|
+
}));
|
|
1436
|
+
}
|
|
1437
|
+
/** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */
|
|
1438
|
+
insertJournalRow(recordId, entry) {
|
|
1439
|
+
this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, entry.seq, entry.at, entry, {
|
|
1440
|
+
version: entry.version,
|
|
1441
|
+
typeId: entry.typeId,
|
|
1442
|
+
parentId: entry.parentId ?? null
|
|
1443
|
+
}));
|
|
1414
1444
|
}
|
|
1415
1445
|
async getJournal(id, query = {}) {
|
|
1416
1446
|
if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
|
|
@@ -1575,7 +1605,51 @@ var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_i
|
|
|
1575
1605
|
var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
|
|
1576
1606
|
var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
|
|
1577
1607
|
|
|
1578
|
-
// ../sqlite-shared/dist/
|
|
1608
|
+
// ../sqlite-shared/dist/config.js
|
|
1609
|
+
var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
|
|
1610
|
+
var missingStoreError = (where) => new Error(`Cannot open: no database found at ${where}. Pass create: 'ifMissing' or 'exclusive' to create one.`);
|
|
1611
|
+
var writeNewStore = (exec, seed) => {
|
|
1612
|
+
exec.transaction(() => {
|
|
1613
|
+
exec.exec(RECORD_SCHEMA_SQL);
|
|
1614
|
+
exec.exec(FTS5_SCHEMA_SQL);
|
|
1615
|
+
exec.run("INSERT INTO format (id, version) VALUES (1, ?)", [FORMAT_VERSION]);
|
|
1616
|
+
new SharedSqlRecordLogic({ exec }).writeImported(seed.config, seed.history);
|
|
1617
|
+
});
|
|
1618
|
+
return seed.config.content;
|
|
1619
|
+
};
|
|
1620
|
+
var readStoredConfig = (exec, where) => {
|
|
1621
|
+
const hasFormat = exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'format'`) !== void 0;
|
|
1622
|
+
if (!hasFormat)
|
|
1623
|
+
return null;
|
|
1624
|
+
const version = exec.get("SELECT version FROM format WHERE id = 1")?.version;
|
|
1625
|
+
if (version !== FORMAT_VERSION)
|
|
1626
|
+
throw new FormatMismatchError(FORMAT_VERSION, version, where);
|
|
1627
|
+
const { content } = exec.get(CONFIG_ROW_SQL);
|
|
1628
|
+
return JSON.parse(content);
|
|
1629
|
+
};
|
|
1630
|
+
var openStore = async (exec, opts) => {
|
|
1631
|
+
const stored = readStoredConfig(exec, opts.where);
|
|
1632
|
+
if (!stored && opts.mode === "never")
|
|
1633
|
+
throw missingStoreError(opts.where);
|
|
1634
|
+
if (stored && (opts.mode === "exclusive" || opts.seed)) {
|
|
1635
|
+
throw new Error(`Cannot create: database already exists at ${opts.where}. Pass create: 'never' or 'ifMissing' without a seed to open it.`);
|
|
1636
|
+
}
|
|
1637
|
+
applyConnectionPragmas(exec, { wal: opts.wal });
|
|
1638
|
+
if (stored) {
|
|
1639
|
+
const expected = typeof opts.ownerEntityId === "string" ? opts.ownerEntityId : void 0;
|
|
1640
|
+
if (expected !== void 0 && expected !== stored.ownerEntityId) {
|
|
1641
|
+
throw new OwnerMismatchError(expected, stored.ownerEntityId, opts.where);
|
|
1642
|
+
}
|
|
1643
|
+
return stored;
|
|
1644
|
+
}
|
|
1645
|
+
if (opts.seed)
|
|
1646
|
+
return writeNewStore(exec, opts.seed);
|
|
1647
|
+
const owner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
|
|
1648
|
+
if (!owner) {
|
|
1649
|
+
throw new Error(`Cannot create: no ownerEntityId given for the new database at ${opts.where}.`);
|
|
1650
|
+
}
|
|
1651
|
+
return writeNewStore(exec, newStoreSeed({ ownerEntityId: owner }));
|
|
1652
|
+
};
|
|
1579
1653
|
var SQLITE_RECORD_CAPABILITIES = {
|
|
1580
1654
|
filter: {
|
|
1581
1655
|
content: "path",
|
|
@@ -1596,12 +1670,17 @@ var SQLITE_RECORD_CAPABILITIES = {
|
|
|
1596
1670
|
var SharedSqlRecordAdapter = class {
|
|
1597
1671
|
capabilities = SQLITE_RECORD_CAPABILITIES;
|
|
1598
1672
|
ownerEntityId;
|
|
1599
|
-
|
|
1673
|
+
/** Each engine holds its database for one process; see docs/spec/adapters.md § Concurrency & storage ownership. */
|
|
1674
|
+
writers = "owner";
|
|
1600
1675
|
record;
|
|
1676
|
+
/** Moved by every write method; see head(). */
|
|
1677
|
+
written = trackWrites(this);
|
|
1601
1678
|
constructor(exec, config) {
|
|
1602
1679
|
this.record = new SharedSqlRecordLogic({ exec });
|
|
1603
|
-
this.ownerEntityId = config.
|
|
1604
|
-
|
|
1680
|
+
this.ownerEntityId = config.ownerEntityId;
|
|
1681
|
+
}
|
|
1682
|
+
async head() {
|
|
1683
|
+
return this.written.read();
|
|
1605
1684
|
}
|
|
1606
1685
|
// -------------------------------------------------------
|
|
1607
1686
|
// Records
|
|
@@ -1609,6 +1688,9 @@ var SharedSqlRecordAdapter = class {
|
|
|
1609
1688
|
createRecord(record, opts) {
|
|
1610
1689
|
return this.record.createRecord(record, opts);
|
|
1611
1690
|
}
|
|
1691
|
+
importRecord(record, history) {
|
|
1692
|
+
return this.record.importRecord(record, history);
|
|
1693
|
+
}
|
|
1612
1694
|
getRecord(id) {
|
|
1613
1695
|
return this.record.getRecord(id);
|
|
1614
1696
|
}
|
|
@@ -1651,9 +1733,6 @@ var SharedSqlRecordAdapter = class {
|
|
|
1651
1733
|
getVersion(id, version) {
|
|
1652
1734
|
return this.record.getVersion(id, version);
|
|
1653
1735
|
}
|
|
1654
|
-
saveVersion(id, version) {
|
|
1655
|
-
return this.record.saveVersion(id, version);
|
|
1656
|
-
}
|
|
1657
1736
|
// -------------------------------------------------------
|
|
1658
1737
|
// Change journal
|
|
1659
1738
|
// -------------------------------------------------------
|
|
@@ -1857,51 +1936,22 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter extends SharedS
|
|
|
1857
1936
|
* See docs/spec/adapters.md § Construction.
|
|
1858
1937
|
*/
|
|
1859
1938
|
static async open(opts) {
|
|
1860
|
-
const mode = opts.create ?? "never";
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
throw new Error(
|
|
1864
|
-
`Cannot open: no database found at "${opts.path}". Pass create: 'ifMissing' or 'exclusive' to create one.`
|
|
1865
|
-
);
|
|
1866
|
-
}
|
|
1867
|
-
if (exists && mode === "exclusive") {
|
|
1868
|
-
throw new Error(
|
|
1869
|
-
`Cannot create: database already exists at "${opts.path}". Pass create: 'never' or 'ifMissing' to open it.`
|
|
1870
|
-
);
|
|
1871
|
-
}
|
|
1872
|
-
};
|
|
1873
|
-
const existedBeforeLock = existsSync(opts.path);
|
|
1874
|
-
checkMode(existedBeforeLock);
|
|
1875
|
-
let newOwner;
|
|
1876
|
-
if (!existedBeforeLock) {
|
|
1877
|
-
newOwner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
|
|
1878
|
-
if (!newOwner) {
|
|
1879
|
-
throw new Error(
|
|
1880
|
-
`Cannot create: no ownerEntityId given for the new database at "${opts.path}".`
|
|
1881
|
-
);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1939
|
+
const mode = opts.seed ? "exclusive" : opts.create ?? "never";
|
|
1940
|
+
const where = `"${opts.path}"`;
|
|
1941
|
+
if (mode === "never" && !existsSync(opts.path)) throw missingStoreError(where);
|
|
1884
1942
|
acquireLock(opts.path, opts.force);
|
|
1943
|
+
const madeFile = !existsSync(opts.path);
|
|
1885
1944
|
let db;
|
|
1886
1945
|
try {
|
|
1887
|
-
const exists = existsSync(opts.path);
|
|
1888
|
-
checkMode(exists);
|
|
1889
1946
|
db = new DatabaseSync(opts.path);
|
|
1890
1947
|
const exec = new NativeSqliteExecutor(db);
|
|
1891
|
-
|
|
1892
|
-
let config;
|
|
1893
|
-
if (exists) {
|
|
1894
|
-
config = readStackConfig(exec);
|
|
1895
|
-
if (typeof opts.ownerEntityId === "string" && opts.ownerEntityId !== config.entityId) {
|
|
1896
|
-
throw new OwnerMismatchError(opts.ownerEntityId, config.entityId, `"${opts.path}"`);
|
|
1897
|
-
}
|
|
1898
|
-
} else {
|
|
1899
|
-
if (!newOwner) throw new Error(`Cannot create: "${opts.path}" vanished while opening.`);
|
|
1900
|
-
config = insertConfigRecord(exec, newOwner, opts.timezone);
|
|
1901
|
-
}
|
|
1948
|
+
const config = await openStore(exec, { ...opts, mode, where, wal: true });
|
|
1902
1949
|
return new _NativeSQLiteRecordAdapter(opts.path, db, exec, config);
|
|
1903
1950
|
} catch (err) {
|
|
1904
1951
|
db?.close();
|
|
1952
|
+
if (madeFile) {
|
|
1953
|
+
for (const suffix of ["", "-wal", "-shm"]) rmSync(`${opts.path}${suffix}`, { force: true });
|
|
1954
|
+
}
|
|
1905
1955
|
releaseLock(opts.path);
|
|
1906
1956
|
throw err;
|
|
1907
1957
|
}
|