@wirecat/cli-core 0.593.0 → 0.595.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/CHANGELOG.md +51 -0
- package/dist/background/schedules.d.ts +10 -1
- package/dist/background/schedules.js +42 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/messenger/store-maintenance-command.js +54 -5
- package/dist/cli/schedule/schedule-command.d.ts +4 -2
- package/dist/cli/schedule/schedule-command.js +3 -1
- package/dist/crm/cli/index.d.ts +6 -0
- package/dist/crm/cli/index.js +118 -2
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +2 -0
- package/dist/crm/testing/index.d.ts +2 -1
- package/dist/crm/testing/index.js +2 -1
- package/dist/crm/testing/source-contract.js +2 -2
- package/dist/crm/testing/source.d.ts +22 -2
- package/dist/crm/testing/source.js +117 -1
- package/dist/crm/testing/sync-contract.js +3 -2
- package/dist/crm/testing/write-contract.d.ts +21 -0
- package/dist/crm/testing/write-contract.js +127 -0
- package/dist/crm/writer.d.ts +76 -0
- package/dist/crm/writer.js +14 -0
- package/dist/crm/writes.d.ts +68 -0
- package/dist/crm/writes.js +245 -0
- package/dist/domain/crm.d.ts +3 -4
- package/dist/entities/index.d.ts +14 -2
- package/dist/entities/store-maintenance.d.ts +14 -2
- package/dist/entities/store-maintenance.js +19 -6
- package/dist/errors/reasons.js +2 -1
- package/dist/mail/cli/review.js +11 -4
- package/dist/mail/marks.d.ts +2 -0
- package/dist/mail/marks.js +8 -6
- package/dist/mail/newsletter.d.ts +10 -0
- package/dist/mail/newsletter.js +70 -0
- package/dist/mail/review.d.ts +1 -0
- package/dist/store/migrations.d.ts +11 -2
- package/dist/store/migrations.js +32 -2
- package/dist/store/sqlite/crm.d.ts +3 -1
- package/dist/store/sqlite/crm.js +37 -20
- package/dist/store/sqlite/deals.js +6 -4
- package/dist/store/sqlite/email-activity.d.ts +4 -0
- package/dist/store/sqlite/email-activity.js +5 -1
- package/dist/store/sqlite/manifest.d.ts +2 -0
- package/dist/store/sqlite/manifest.js +58 -5
- package/dist/store/sqlite/migrations.generated.js +9 -0
- package/dist/store/sqlite/open-store.js +8 -1
- package/dist/store/sqlite/rollback-copy.d.ts +49 -0
- package/dist/store/sqlite/rollback-copy.js +290 -0
- package/dist/store/sqlite/schema.d.ts +32 -0
- package/dist/store/sqlite/schema.js +4 -2
- package/dist/store/store.d.ts +1 -0
- package/dist/store/store.js +1 -0
- package/package.json +2 -2
package/dist/store/migrations.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { lockedOutNotice, recordClient, refusalAdvice, storeClients } from "./sqlite/clients.js";
|
|
3
3
|
import { generatedMigrations } from "./sqlite/manifest.js";
|
|
4
|
+
import { discardRollbackCopy, recordRollbackCopy, takeRollbackCopy, } from "./sqlite/rollback-copy.js";
|
|
4
5
|
/**
|
|
5
6
|
* Every migration is generated by drizzle-kit and numbered in `sqlite/manifest.ts`. Version 1 creates the whole
|
|
6
7
|
* schema. **A migration that has reached anyone's file is never edited.** After it: forward-only and additive —
|
|
7
8
|
* no `DROP` and no rebuild of a base table, a new column is nullable or has a default, and every `INSERT` in the
|
|
8
9
|
* store names its columns — so an older `tg` keeps writing to a file a newer `max` has migrated. Only a breaking
|
|
9
|
-
* change raises `minCompatible`, and that is a major version of this package
|
|
10
|
+
* change raises `minCompatible`, and that is a major version of this package; it names the base tables it changes
|
|
11
|
+
* (`tables`), which are copied aside before it runs (`sqlite/rollback-copy.ts`). The search indexes are derived and
|
|
10
12
|
* may be dropped and rebuilt. Every version after the first gets a `version-N.test.ts` that opens a file at N-1.
|
|
11
13
|
*
|
|
12
14
|
* Times are epoch milliseconds: an integer sorts and ranges without a format to agree on.
|
|
@@ -31,18 +33,44 @@ const fileState = (database) => {
|
|
|
31
33
|
.get();
|
|
32
34
|
return { version: Number(row?.version ?? 0), minCompatible: Number(row?.min_compatible ?? 0) };
|
|
33
35
|
};
|
|
36
|
+
/** The tables every pending migration that raises `minCompatible` names; none for a new file or a foreign one. */
|
|
37
|
+
const copyBeforeBreaking = (database, migrations, { ownTable, now }, options) => {
|
|
38
|
+
if (!hasTable(database, "schema_migrations"))
|
|
39
|
+
return undefined;
|
|
40
|
+
const file = fileState(database);
|
|
41
|
+
if (file.version === 0 || (ownTable !== null && !hasTable(database, ownTable)))
|
|
42
|
+
return undefined;
|
|
43
|
+
let floor = file.minCompatible;
|
|
44
|
+
const tables = [];
|
|
45
|
+
for (const migration of migrations) {
|
|
46
|
+
if (migration.version <= file.version || migration.minCompatible <= floor)
|
|
47
|
+
continue;
|
|
48
|
+
floor = migration.minCompatible;
|
|
49
|
+
for (const table of migration.tables ?? [])
|
|
50
|
+
tables.push(table);
|
|
51
|
+
}
|
|
52
|
+
if (floor === file.minCompatible)
|
|
53
|
+
return undefined;
|
|
54
|
+
return takeRollbackCopy(database, { from: file.version, to: latest(migrations), tables, at: now() }, options);
|
|
55
|
+
};
|
|
34
56
|
/**
|
|
35
57
|
* Brings the file up to this version, or opens a newer one it can still write to, or refuses.
|
|
36
58
|
* Inside `BEGIN IMMEDIATE`, so two processes opening an old file at once migrate it once. A migration
|
|
37
59
|
* that fails names `store reset` when the app has one: without it the owner is stuck until a build that can migrate
|
|
38
60
|
* the file. An app without that command (drive) passes no `command`, and the advice names none.
|
|
39
61
|
*/
|
|
40
|
-
export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, command, version, ownTable = OWN_TABLE, } = {}) => {
|
|
62
|
+
export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, command, version, ownTable = OWN_TABLE, rollback, } = {}) => {
|
|
41
63
|
const ours = latest(migrations);
|
|
64
|
+
let copy = rollback ? copyBeforeBreaking(database, migrations, { ownTable, now }, rollback) : undefined;
|
|
42
65
|
database.exec("BEGIN IMMEDIATE");
|
|
43
66
|
try {
|
|
44
67
|
database.exec(HISTORY);
|
|
45
68
|
const file = fileState(database);
|
|
69
|
+
// Another process migrated the file between the copy and this transaction, and took its own.
|
|
70
|
+
if (copy && copy.from !== file.version) {
|
|
71
|
+
discardRollbackCopy(copy);
|
|
72
|
+
copy = undefined;
|
|
73
|
+
}
|
|
46
74
|
if (file.version > 0 && ownTable !== null && !hasTable(database, ownTable)) {
|
|
47
75
|
throw new CliError("configuration_error", `the message store was written by another, incompatible tool (schema ${file.version} by its own numbering, ` +
|
|
48
76
|
"with no meetings table) — this tool will not open or change it; move it aside to start a new store");
|
|
@@ -73,6 +101,8 @@ export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, com
|
|
|
73
101
|
}
|
|
74
102
|
if (command && version)
|
|
75
103
|
recordClient(database, { command, version, speaks: ours }, now());
|
|
104
|
+
if (copy)
|
|
105
|
+
recordRollbackCopy(database, copy, now());
|
|
76
106
|
database.exec("COMMIT");
|
|
77
107
|
return notice;
|
|
78
108
|
}
|
|
@@ -6,7 +6,7 @@ import type { StoreContext } from "./open.js";
|
|
|
6
6
|
/** What the store could not keep, and why: an item whose stage is not stored or whose field it refuses, or a pipeline. */
|
|
7
7
|
export interface CrmSkipped {
|
|
8
8
|
externalId: string;
|
|
9
|
-
reason: "crm.stage-unknown" | "crm.item-invalid"
|
|
9
|
+
reason: "crm.stage-unknown" | "crm.item-invalid";
|
|
10
10
|
message: string;
|
|
11
11
|
}
|
|
12
12
|
export interface CrmSaved {
|
|
@@ -39,6 +39,8 @@ export interface CrmCopies {
|
|
|
39
39
|
}): Promise<number>;
|
|
40
40
|
/** How many of the kind the account holds, not deleted; every contact it ever saved. */
|
|
41
41
|
count(key: AccountKey, kind: CrmKind): Promise<number>;
|
|
42
|
+
/** The stored copy's `deal:<id>` or `lead:<id>`, deleted or not; `null` when the store holds none. */
|
|
43
|
+
refOf(key: AccountKey, kind: DealKind, externalId: string): Promise<string | null>;
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
46
|
* A contact's identity id: identities are unique per provider, and two portals of one CRM both have a contact 5.
|
package/dist/store/sqlite/crm.js
CHANGED
|
@@ -36,24 +36,14 @@ export const crmCopiesOver = (context, people) => {
|
|
|
36
36
|
const pipelineOf = (accountId, kind, externalId) => database
|
|
37
37
|
.prepare("SELECT * FROM pipelines WHERE account_id = ? AND kind = ? AND external_id = ?")
|
|
38
38
|
.get(accountId, kind, externalId);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.get(accountId, kind, externalId) !== undefined;
|
|
39
|
+
/**
|
|
40
|
+
* One kind's whole list: a pipeline it lacks is marked deleted, one listed again comes back. An empty list marks
|
|
41
|
+
* nothing: a sign-in that lost its CRM rights lists no pipeline at all.
|
|
42
|
+
*/
|
|
44
43
|
const savePipelines = (key, kind, pipelines) => write(database, () => {
|
|
45
44
|
const accountId = account(key);
|
|
46
45
|
const at = now();
|
|
47
|
-
const skipped = [];
|
|
48
46
|
for (const pipeline of pipelines) {
|
|
49
|
-
if (otherKindHolds(accountId, kind, pipeline.externalId)) {
|
|
50
|
-
skipped.push({
|
|
51
|
-
externalId: pipeline.externalId,
|
|
52
|
-
reason: "crm.pipeline-clash",
|
|
53
|
-
message: `${kind} pipeline ${pipeline.externalId} has the id of a stored pipeline of the other kind`,
|
|
54
|
-
});
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
47
|
const name = textOf(pipeline.name, 80) ?? pipeline.externalId;
|
|
58
48
|
const held = pipelineOf(accountId, kind, pipeline.externalId);
|
|
59
49
|
const id = held
|
|
@@ -62,13 +52,22 @@ export const crmCopiesOver = (context, people) => {
|
|
|
62
52
|
.prepare("INSERT INTO pipelines (account_id, external_id, kind, name, name_folded, created_at, updated_at) " +
|
|
63
53
|
"VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")
|
|
64
54
|
.get(accountId, pipeline.externalId, kind, name, fold(name), at, at)?.id);
|
|
65
|
-
if (held && held.name !== name)
|
|
55
|
+
if (held && (held.name !== name || held.deleted_at != null))
|
|
66
56
|
database
|
|
67
|
-
.prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at =
|
|
57
|
+
.prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at = ?, deleted_at = NULL WHERE id = ?")
|
|
68
58
|
.run(name, fold(name), at, id);
|
|
69
59
|
saveStages(id, pipeline, at);
|
|
70
60
|
}
|
|
71
|
-
|
|
61
|
+
if (pipelines.length === 0)
|
|
62
|
+
return { saved: 0, skipped: [] };
|
|
63
|
+
const listed = new Set(pipelines.map(({ externalId }) => externalId));
|
|
64
|
+
const mark = database.prepare("UPDATE pipelines SET deleted_at = ?, updated_at = ? WHERE id = ?");
|
|
65
|
+
for (const row of database
|
|
66
|
+
.prepare("SELECT id, external_id FROM pipelines WHERE account_id = ? AND kind = ? AND deleted_at IS NULL")
|
|
67
|
+
.all(accountId, kind))
|
|
68
|
+
if (!listed.has(String(row.external_id)))
|
|
69
|
+
mark.run(at, at, Number(row.id));
|
|
70
|
+
return { saved: pipelines.length, skipped: [] };
|
|
72
71
|
});
|
|
73
72
|
/**
|
|
74
73
|
* Stage names are unique in a pipeline, but a CRM may swap two, or give two stages one name: listed stages first
|
|
@@ -90,18 +89,23 @@ export const crmCopiesOver = (context, people) => {
|
|
|
90
89
|
const listed = new Set(wanted.map(({ externalId }) => externalId));
|
|
91
90
|
const taken = new Set(wanted.map(({ folded }) => folded));
|
|
92
91
|
const rename = database.prepare("UPDATE pipeline_stages SET name_folded = ? WHERE id = ?");
|
|
92
|
+
const gone = database.prepare("UPDATE pipeline_stages SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL");
|
|
93
93
|
for (const row of held) {
|
|
94
|
-
|
|
94
|
+
const kept = row.external_id != null && listed.has(String(row.external_id));
|
|
95
|
+
if (kept)
|
|
95
96
|
rename.run(`\u0000${row.id}`, Number(row.id));
|
|
96
97
|
else if (taken.has(String(row.name_folded)))
|
|
97
98
|
rename.run(`${row.name_folded} (${row.external_id ?? row.id})`, Number(row.id));
|
|
99
|
+
if (!kept)
|
|
100
|
+
gone.run(at, Number(row.id));
|
|
98
101
|
}
|
|
99
102
|
const byId = new Map(held.map((row) => [String(row.external_id), row]));
|
|
100
103
|
for (const stage of wanted) {
|
|
101
104
|
const row = byId.get(stage.externalId);
|
|
102
105
|
if (row)
|
|
103
106
|
database
|
|
104
|
-
.prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at =
|
|
107
|
+
.prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at = ?, " +
|
|
108
|
+
"deleted_at = NULL WHERE id = ?")
|
|
105
109
|
.run(stage.name, stage.folded, stage.position, stage.outcome, at, Number(row.id));
|
|
106
110
|
else
|
|
107
111
|
database
|
|
@@ -151,7 +155,11 @@ export const crmCopiesOver = (context, people) => {
|
|
|
151
155
|
const deal = kind === "lead" && item.deal
|
|
152
156
|
? database.prepare("SELECT id FROM deals WHERE account_id = ? AND external_id = ?").get(accountId, item.deal)
|
|
153
157
|
: undefined;
|
|
154
|
-
const metadata = {
|
|
158
|
+
const metadata = {
|
|
159
|
+
...(item.metadata ?? {}),
|
|
160
|
+
...(item.url ? { url: item.url } : {}),
|
|
161
|
+
...(item.closedReason && status !== "open" ? { closed_reason: textOf(item.closedReason, 2000) } : {}),
|
|
162
|
+
};
|
|
155
163
|
const values = {
|
|
156
164
|
title,
|
|
157
165
|
organization: item.organization ? organizationIdOf(accountId, item.organization) : null,
|
|
@@ -314,5 +322,14 @@ export const crmCopiesOver = (context, people) => {
|
|
|
314
322
|
.prepare("SELECT count(*) AS n FROM identities WHERE provider = ? AND substr(external_id, 1, ?) = ?")
|
|
315
323
|
.get(key.provider, key.account.length + 1, `${key.account}/`)?.n)
|
|
316
324
|
: stored(key, kind).length,
|
|
325
|
+
refOf: async (key, kind, externalId) => {
|
|
326
|
+
const accountId = findAccountPk(context, key);
|
|
327
|
+
const row = accountId === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: database
|
|
330
|
+
.prepare(`SELECT id FROM ${TABLES[kind]} WHERE account_id = ? AND external_id = ?`)
|
|
331
|
+
.get(accountId, externalId);
|
|
332
|
+
return row ? `${kind}:${row.id}` : null;
|
|
333
|
+
},
|
|
317
334
|
};
|
|
318
335
|
};
|
|
@@ -94,9 +94,11 @@ export const dealOperationsOver = (context) => {
|
|
|
94
94
|
? row.account_id == null
|
|
95
95
|
: row.account_provider === scope.provider && row.account_external_id === scope.account);
|
|
96
96
|
const kindOf = (kind) => (kind === undefined ? "deal" : oneOf(kind, DEAL_KINDS, "a kind"));
|
|
97
|
-
|
|
97
|
+
/** A pipeline the CRM deleted is left out, unless named by its reference: its deals still stand in it. */
|
|
98
|
+
const pipelineRows = (kind, scope, { deleted = false } = {}) => database
|
|
98
99
|
.prepare("SELECT p.*, a.provider AS account_provider, a.external_id AS account_external_id FROM pipelines p " +
|
|
99
|
-
"LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?)
|
|
100
|
+
"LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?) " +
|
|
101
|
+
`${deleted ? "" : "AND p.deleted_at IS NULL "}ORDER BY p.name_folded, p.id`)
|
|
100
102
|
.all(kind ?? null, kind ?? null)
|
|
101
103
|
.filter((row) => inScope(scope, row));
|
|
102
104
|
const label = (row) => `pipeline:${row.id} (${row.name}${row.account_provider == null ? "" : `, ${row.account_provider}:${row.account_external_id}`})`;
|
|
@@ -104,7 +106,7 @@ export const dealOperationsOver = (context) => {
|
|
|
104
106
|
const pipelineRow = (reference, kind, scope) => {
|
|
105
107
|
const named = reference.trim();
|
|
106
108
|
if (/^pipeline:\d+$/.test(named)) {
|
|
107
|
-
const row = pipelineRows(kind, scope).find((one) => `pipeline:${one.id}` === named);
|
|
109
|
+
const row = pipelineRows(kind, scope, { deleted: true }).find((one) => `pipeline:${one.id}` === named);
|
|
108
110
|
if (!row)
|
|
109
111
|
throw new CliError("not_found", `no ${kind ?? ""} pipeline ${named}`, { reason: "pipeline.not-found" });
|
|
110
112
|
return row;
|
|
@@ -267,7 +269,7 @@ export const dealOperationsOver = (context) => {
|
|
|
267
269
|
});
|
|
268
270
|
return made;
|
|
269
271
|
});
|
|
270
|
-
return pipelinesOf(pipelineRows(pipelineKind).filter((row) => Number(row.id) === id))[0];
|
|
272
|
+
return pipelinesOf(pipelineRows(pipelineKind, undefined, { deleted: true }).filter((row) => Number(row.id) === id))[0];
|
|
271
273
|
},
|
|
272
274
|
pipelines: (filter = {}) => pipelinesOf(pipelineRows(filter.kind === undefined ? undefined : kindOf(filter.kind), filter.account)),
|
|
273
275
|
pipeline: (reference, options = {}) => {
|
|
@@ -54,6 +54,10 @@ export interface MarkedEmail {
|
|
|
54
54
|
changedAt: number;
|
|
55
55
|
/** Sent to many: the bulk mailbox or a List-Id. */
|
|
56
56
|
bulk: boolean;
|
|
57
|
+
/** Its own List-Id: a real mailing list, as opposed to List-Unsubscribe or Precedence alone. */
|
|
58
|
+
listed: boolean;
|
|
59
|
+
subject: string | null;
|
|
60
|
+
from: string | null;
|
|
57
61
|
envelope: boolean;
|
|
58
62
|
/** Stored from its first bytes, not whole yet. */
|
|
59
63
|
partial: boolean;
|
|
@@ -100,7 +100,8 @@ export const writtenTo = (database, addresses) => {
|
|
|
100
100
|
};
|
|
101
101
|
const MARKED = `
|
|
102
102
|
SELECT e.id, e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.created_at,
|
|
103
|
-
e.updated_at,
|
|
103
|
+
e.updated_at, e.subject, e.from_address, coalesce(e.draft, 0) AS draft,
|
|
104
|
+
coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) IS NOT NULL AS listed, json_extract(e.metadata, '$.partial') IS NOT NULL AS partial,
|
|
104
105
|
coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) IS NOT NULL
|
|
105
106
|
OR EXISTS (SELECT 1 FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
106
107
|
WHERE m.email_id = e.id AND b.kind = 'bulk') AS bulk,
|
|
@@ -117,6 +118,9 @@ const markedOf = (row) => ({
|
|
|
117
118
|
storedAt: Number(row.created_at),
|
|
118
119
|
changedAt: Number(row.updated_at),
|
|
119
120
|
bulk: Boolean(row.bulk),
|
|
121
|
+
listed: Boolean(row.listed),
|
|
122
|
+
subject: row.subject === null || row.subject === undefined ? null : String(row.subject),
|
|
123
|
+
from: row.from_address === null || row.from_address === undefined ? null : String(row.from_address),
|
|
120
124
|
envelope: Boolean(row.envelope),
|
|
121
125
|
partial: Boolean(row.partial),
|
|
122
126
|
draft: Boolean(row.draft),
|
|
@@ -11,6 +11,8 @@ export interface ManifestEntry {
|
|
|
11
11
|
name: string;
|
|
12
12
|
version: number;
|
|
13
13
|
minCompatible: number;
|
|
14
|
+
/** Required on a version that raises `minCompatible`: the base tables it changes (`Migration.tables`). */
|
|
15
|
+
tables?: string[];
|
|
14
16
|
}
|
|
15
17
|
/** One migration creates the whole schema; every later one is added as the next version. */
|
|
16
18
|
export declare const MANIFEST: ManifestEntry[];
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
import { GENERATED } from "./migrations.generated.js";
|
|
2
|
+
import { isDerivedTable } from "./rollback-copy.js";
|
|
3
|
+
const V31_TABLES = [
|
|
4
|
+
"accounts",
|
|
5
|
+
"account_identities",
|
|
6
|
+
"agent_actions",
|
|
7
|
+
"aliases",
|
|
8
|
+
"attachments",
|
|
9
|
+
"bot_updates",
|
|
10
|
+
"chats",
|
|
11
|
+
"documents",
|
|
12
|
+
"email_threads",
|
|
13
|
+
"emails",
|
|
14
|
+
"facts",
|
|
15
|
+
"links",
|
|
16
|
+
"mail_rules",
|
|
17
|
+
"mailboxes",
|
|
18
|
+
"meeting_series",
|
|
19
|
+
"meetings",
|
|
20
|
+
"memories",
|
|
21
|
+
"messages",
|
|
22
|
+
"notes",
|
|
23
|
+
"projects",
|
|
24
|
+
"proposed_actions",
|
|
25
|
+
"reminders",
|
|
26
|
+
"store_settings",
|
|
27
|
+
"sync_cursors",
|
|
28
|
+
"syncs",
|
|
29
|
+
"taggings",
|
|
30
|
+
];
|
|
2
31
|
/** One migration creates the whole schema; every later one is added as the next version. */
|
|
3
32
|
export const MANIFEST = [
|
|
4
33
|
{ name: "20261010184427_initial", version: 1, minCompatible: 1 },
|
|
@@ -36,14 +65,24 @@ export const MANIFEST = [
|
|
|
36
65
|
{ name: "20261021224625_version-28-chat-state", version: 28, minCompatible: 1 },
|
|
37
66
|
{ name: "20261023105057_version-29-transcript-words", version: 29, minCompatible: 1 },
|
|
38
67
|
{ name: "20261023105101_version-29-transcript-words-triggers", version: 29, minCompatible: 1 },
|
|
39
|
-
{
|
|
68
|
+
{
|
|
69
|
+
name: "20261025064253_version-30-reviewed-tag-consolidation",
|
|
70
|
+
version: 30,
|
|
71
|
+
minCompatible: 30,
|
|
72
|
+
tables: ["tags", "auto_tag_claims"],
|
|
73
|
+
},
|
|
40
74
|
{ name: "20261025064302_version-30-resolved-tag-view", version: 30, minCompatible: 30 },
|
|
41
|
-
{ name: "20261025070402_version-31-drive-account", version: 31, minCompatible: 31 },
|
|
42
|
-
{
|
|
75
|
+
{ name: "20261025070402_version-31-drive-account", version: 31, minCompatible: 31, tables: V31_TABLES },
|
|
76
|
+
{
|
|
77
|
+
name: "20261025071848_version-32-files",
|
|
78
|
+
version: 32,
|
|
79
|
+
minCompatible: 32,
|
|
80
|
+
tables: ["accounts", "documents", "identities", "store_settings"],
|
|
81
|
+
},
|
|
43
82
|
{ name: "20261025071850_version-32-files-data", version: 32, minCompatible: 32 },
|
|
44
83
|
{ name: "20261025072643_version-33-calendars", version: 33, minCompatible: 32 },
|
|
45
84
|
{ name: "20261025072644_version-33-calendars-triggers", version: 33, minCompatible: 32 },
|
|
46
|
-
{ name: "20261025182500_version-34-vector-index", version: 34, minCompatible: 34 },
|
|
85
|
+
{ name: "20261025182500_version-34-vector-index", version: 34, minCompatible: 34, tables: [] },
|
|
47
86
|
{ name: "20261025182501_version-34-vector-index-triggers", version: 34, minCompatible: 34 },
|
|
48
87
|
{ name: "20261027102259_version-35-calendar-neutral", version: 35, minCompatible: 34 },
|
|
49
88
|
{ name: "20261027102302_version-35-calendar-neutral-data", version: 35, minCompatible: 34 },
|
|
@@ -70,6 +109,7 @@ export const MANIFEST = [
|
|
|
70
109
|
{ name: "20261105144549_version-51-task-links", version: 51, minCompatible: 34 },
|
|
71
110
|
{ name: "20261106010504_version-52-deals", version: 52, minCompatible: 34 },
|
|
72
111
|
{ name: "20261106010507_version-52-deals-rules", version: 52, minCompatible: 34 },
|
|
112
|
+
{ name: "20261106165009_version-53-crm-pipelines", version: 53, minCompatible: 34 },
|
|
73
113
|
];
|
|
74
114
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
75
115
|
const migrations = [];
|
|
@@ -82,12 +122,25 @@ export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED)
|
|
|
82
122
|
const last = migrations.at(-1);
|
|
83
123
|
if (last && last.version !== entry.version && entry.minCompatible < last.minCompatible)
|
|
84
124
|
throw new Error("migration minCompatible cannot decrease");
|
|
125
|
+
const derived = entry.tables?.filter(isDerivedTable) ?? [];
|
|
126
|
+
if (derived.length > 0)
|
|
127
|
+
throw new Error(`migration ${entry.version} names derived tables (${derived.join(", ")}); they are rebuilt, never copied`);
|
|
85
128
|
if (last?.version !== entry.version) {
|
|
86
|
-
|
|
129
|
+
if (last && entry.minCompatible > last.minCompatible && entry.tables === undefined)
|
|
130
|
+
throw new Error(`migration ${entry.version} raises minCompatible, so it names the base tables it changes (tables)`);
|
|
131
|
+
migrations.push({
|
|
132
|
+
version: entry.version,
|
|
133
|
+
minCompatible: entry.minCompatible,
|
|
134
|
+
statements: [...found.statements],
|
|
135
|
+
...(entry.tables ? { tables: [...entry.tables] } : {}),
|
|
136
|
+
});
|
|
87
137
|
}
|
|
88
138
|
else if (last.minCompatible !== entry.minCompatible) {
|
|
89
139
|
throw new Error(`the rows of version ${entry.version} disagree on minCompatible`);
|
|
90
140
|
}
|
|
141
|
+
else if (entry.tables && JSON.stringify(entry.tables) !== JSON.stringify(last.tables)) {
|
|
142
|
+
throw new Error(`the rows of version ${entry.version} disagree on tables`);
|
|
143
|
+
}
|
|
91
144
|
else {
|
|
92
145
|
last.statements.push(...found.statements);
|
|
93
146
|
}
|
|
@@ -1033,5 +1033,14 @@ export const GENERATED = [
|
|
|
1033
1033
|
"-- The default lead pipeline, so a lead can be recorded before the owner or an agent makes one.\nINSERT INTO pipelines (kind, name, name_folded, created_at, updated_at)\n VALUES ('lead', 'Leads', 'leads', CAST(strftime('%s', 'now') AS INTEGER) * 1000, CAST(strftime('%s', 'now') AS INTEGER) * 1000);",
|
|
1034
1034
|
"INSERT INTO pipeline_stages (pipeline_id, name, name_folded, position, outcome, created_at, updated_at)\n SELECT p.id, v.name, v.name, v.position, v.outcome, p.created_at, p.created_at FROM pipelines p,\n (SELECT 'new' AS name, 1 AS position, 'open' AS outcome UNION ALL SELECT 'contacted', 2, 'open'\n UNION ALL SELECT 'qualified', 3, 'open' UNION ALL SELECT 'won', 4, 'won' UNION ALL SELECT 'lost', 5, 'lost') v\n WHERE p.kind = 'lead' AND p.name_folded = 'leads' AND p.account_id IS NULL;"
|
|
1035
1035
|
]
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
"name": "20261106165009_version-53-crm-pipelines",
|
|
1039
|
+
"statements": [
|
|
1040
|
+
"ALTER TABLE `pipeline_stages` ADD `deleted_at` integer;",
|
|
1041
|
+
"ALTER TABLE `pipelines` ADD `deleted_at` integer;",
|
|
1042
|
+
"DROP INDEX IF EXISTS `pipelines_by_account_id_external_id`;",
|
|
1043
|
+
"CREATE UNIQUE INDEX `pipelines_by_account_id_kind_external_id` ON `pipelines` (`account_id`,`kind`,`external_id`) WHERE account_id IS NOT NULL AND external_id IS NOT NULL;"
|
|
1044
|
+
]
|
|
1036
1045
|
}
|
|
1037
1046
|
];
|
|
@@ -15,6 +15,7 @@ import { openSqlite } from "./open.js";
|
|
|
15
15
|
import { movePaymentFacts } from "./payment-facts.js";
|
|
16
16
|
import { loadProviders } from "./providers.js";
|
|
17
17
|
import { assertReadCompatible } from "./read-compatibility.js";
|
|
18
|
+
import { pruneRollbackCopies } from "./rollback-copy.js";
|
|
18
19
|
import { stemsState } from "./stems.js";
|
|
19
20
|
import { fillStems, followDefaultStemmers } from "./stems-fill.js";
|
|
20
21
|
import { moveSuggestedLinks } from "./suggested-links.js";
|
|
@@ -53,9 +54,15 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
|
|
|
53
54
|
if (readOnly)
|
|
54
55
|
assertReadCompatible(database, command);
|
|
55
56
|
else {
|
|
56
|
-
const notice = migrate(database, {
|
|
57
|
+
const notice = migrate(database, {
|
|
58
|
+
now,
|
|
59
|
+
command,
|
|
60
|
+
...(version ? { version } : {}),
|
|
61
|
+
rollback: { store: file, warn },
|
|
62
|
+
});
|
|
57
63
|
if (notice)
|
|
58
64
|
warn(notice);
|
|
65
|
+
pruneRollbackCopies(database, now());
|
|
59
66
|
fillFiles(database, now);
|
|
60
67
|
fillFolded(database);
|
|
61
68
|
fillJoinKeys(database);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
2
|
+
export declare const isDerivedTable: (name: string) => boolean;
|
|
3
|
+
export interface RollbackCopyOptions {
|
|
4
|
+
/** The store's own path: the copy goes in `backups/` beside it. */
|
|
5
|
+
store: string;
|
|
6
|
+
freeBytes?: (dir: string) => number;
|
|
7
|
+
warn?: (line: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface RollbackCopy {
|
|
10
|
+
path: string;
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Outside the migration's transaction (SQLite cannot attach inside one), committed on its own, so a migration that
|
|
16
|
+
* fails leaves it whole. Never blocks the migration: no room, or any failure, is a warning and no copy.
|
|
17
|
+
*/
|
|
18
|
+
export declare const takeRollbackCopy: (database: CacheDatabase, { from, to, tables, at }: {
|
|
19
|
+
from: number;
|
|
20
|
+
to: number;
|
|
21
|
+
tables: string[];
|
|
22
|
+
at: number;
|
|
23
|
+
}, { store, freeBytes, warn }: RollbackCopyOptions) => RollbackCopy | undefined;
|
|
24
|
+
/** Inside the migration's transaction: a migration that fails never records its copy, so cleanup never takes it. */
|
|
25
|
+
export declare const recordRollbackCopy: (database: CacheDatabase, copy: RollbackCopy, at: number) => void;
|
|
26
|
+
export declare const discardRollbackCopy: (copy: RollbackCopy) => void;
|
|
27
|
+
/**
|
|
28
|
+
* The day after: a copy recorded by a migration that committed, once the store is still at that version. Only the
|
|
29
|
+
* recorded file is deleted, never anything else in `backups/`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const pruneRollbackCopies: (database: CacheDatabase, now: number) => void;
|
|
32
|
+
export interface RollbackCopyMeta {
|
|
33
|
+
from: number;
|
|
34
|
+
to: number;
|
|
35
|
+
tables: string[];
|
|
36
|
+
createdAt: number;
|
|
37
|
+
}
|
|
38
|
+
export interface RollbackResult extends RollbackCopyMeta {
|
|
39
|
+
/** The schema the store was at before the rollback. */
|
|
40
|
+
was: number;
|
|
41
|
+
restored: {
|
|
42
|
+
table: string;
|
|
43
|
+
rows: number;
|
|
44
|
+
}[];
|
|
45
|
+
rebuilt: string[];
|
|
46
|
+
foreignKeyViolations: number;
|
|
47
|
+
}
|
|
48
|
+
/** Puts a rollback copy back into the store, in place. The caller makes sure nothing else has the store open. */
|
|
49
|
+
export declare const rollBack: (database: CacheDatabase, path: string) => RollbackResult;
|