2769 lines
143 KiB
JavaScript
2769 lines
143 KiB
JavaScript
// Generated, do not edit. sha256:40ff653075fba536c72875e2aaa7a896d17bbea56edc4438b0f2dcada32718e2
|
|
import { CONFIG } from '../../shared/config.js';
|
|
import { run, one, all } from './conn.mjs';
|
|
|
|
export const BASELINE_VERSION = 60;
|
|
|
|
const MIGRATIONS = {
|
|
61: () => {
|
|
run(`CREATE TABLE AiToolCall (
|
|
id INTEGER PRIMARY KEY,
|
|
sessionId TEXT NOT NULL,
|
|
client TEXT,
|
|
tool TEXT NOT NULL,
|
|
arguments TEXT,
|
|
fieldsShared TEXT,
|
|
charactersOut INTEGER CHECK (charactersOut IS NULL OR charactersOut >= 0),
|
|
outcome TEXT NOT NULL CHECK (outcome IN ('ok','error','refused')),
|
|
detail TEXT,
|
|
occurredAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`CREATE INDEX IxAiToolCallOccurredAt ON AiToolCall(occurredAt);`);
|
|
},
|
|
|
|
62: () => {
|
|
run(`ALTER TABLE AiToolCall ADD COLUMN serverVersion TEXT`);
|
|
},
|
|
|
|
63: () => {
|
|
run(`CREATE TABLE AiToolCallMessage (
|
|
id INTEGER PRIMARY KEY,
|
|
callId INTEGER NOT NULL REFERENCES AiToolCall(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('request','response')),
|
|
text TEXT NOT NULL,
|
|
occurredAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`CREATE INDEX IxAiToolCallMessageCallId ON AiToolCallMessage(callId);`);
|
|
},
|
|
|
|
64: () => {
|
|
run(`CREATE TABLE BusSignal (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
occurredAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
},
|
|
|
|
65: () => {
|
|
run(`CREATE TABLE Command (
|
|
id INTEGER PRIMARY KEY,
|
|
kind TEXT NOT NULL CHECK (kind IN ('navigate')),
|
|
payload TEXT,
|
|
state TEXT NOT NULL CHECK (state IN ('open','claimed','done','rejected','expired')),
|
|
claimedBy TEXT,
|
|
claimedAt TEXT,
|
|
expiresAt TEXT,
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`UPDATE Preference SET value = '5' WHERE scope = 'timers' AND key = 'bus_poll_seconds' AND value = '15'`);
|
|
},
|
|
|
|
66: () => {
|
|
run(`ALTER TABLE Command RENAME TO CommandOud`);
|
|
run(`CREATE TABLE Command (
|
|
id INTEGER PRIMARY KEY,
|
|
kind TEXT NOT NULL CHECK (kind IN ('navigate','locate')),
|
|
payload TEXT,
|
|
state TEXT NOT NULL CHECK (state IN ('open','claimed','done','rejected','expired')),
|
|
claimedBy TEXT,
|
|
claimedAt TEXT,
|
|
expiresAt TEXT,
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`INSERT INTO Command (id, kind, payload, state, claimedBy, claimedAt, expiresAt, detail, resolvedAt, createdAt)
|
|
SELECT id, kind, payload, state, claimedBy, claimedAt, expiresAt, detail, resolvedAt, createdAt FROM CommandOud`);
|
|
run(`DROP TABLE CommandOud`);
|
|
|
|
run(`ALTER TABLE Notification RENAME TO NotificationOud`);
|
|
run(`CREATE TABLE Notification (
|
|
id INTEGER PRIMARY KEY,
|
|
count INTEGER NOT NULL CHECK (count >= 0),
|
|
highWater INTEGER NOT NULL CHECK (highWater >= 0),
|
|
kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_command')),
|
|
dismissedAt TEXT,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`INSERT INTO Notification (id, count, highWater, kind, dismissedAt, createdAt)
|
|
SELECT id, count, highWater, kind, dismissedAt, createdAt FROM NotificationOud`);
|
|
run(`DROP TABLE NotificationOud`);
|
|
run(`CREATE INDEX IxNotificationKind ON Notification(kind)`);
|
|
},
|
|
|
|
67: () => {
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point')),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
observedAt TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL))
|
|
) STRICT;`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
},
|
|
|
|
68: () => {
|
|
const telAsset = one(`SELECT COUNT(*) AS n FROM Asset WHERE decimals > 9`).n;
|
|
if (telAsset > 0) {
|
|
throw new Error(
|
|
`Migration 68 cannot run: ${telAsset === 1 ? 'there is 1 asset' : `there are ${telAsset} assets`} with ` +
|
|
`more than 9 decimals. Such an asset is unusable (an amount above 0.009 unit can no longer be read ` +
|
|
`back) and the new schema forbids it. Change the decimals of that asset to at most 9, or remove it, ` +
|
|
`and start again.`);
|
|
}
|
|
const telGeld = one(`SELECT COUNT(*) AS n FROM Event
|
|
WHERE eurPrice < 0 OR eurFee < 0 OR unitFee < 0`).n;
|
|
if (telGeld > 0) {
|
|
throw new Error(
|
|
`Migration 68 cannot run: ${telGeld === 1 ? 'there is 1 event' : `there are ${telGeld} events`} with a ` +
|
|
`negative eurPrice, eurFee or unitFee. The new schema forbids that, because a negative fee raises ` +
|
|
`the balance. Correct those events and start again.`);
|
|
}
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE Asset RENAME TO AssetOud`);
|
|
run(`CREATE TABLE Asset (
|
|
id INTEGER PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
unit TEXT NOT NULL,
|
|
subunit TEXT,
|
|
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
|
|
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
|
|
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal')),
|
|
UNIQUE (symbol),
|
|
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind)
|
|
SELECT id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind FROM AssetOud`);
|
|
run(`DROP TABLE AssetOud`);
|
|
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
|
|
|
|
run(`ALTER TABLE Event RENAME TO EventOud`);
|
|
run(`CREATE TABLE Event (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION,
|
|
productCount INTEGER CHECK (productCount IS NULL OR productCount > 0),
|
|
importReference TEXT,
|
|
externalAddress TEXT,
|
|
note TEXT,
|
|
amount INTEGER NOT NULL CHECK (amount > 0),
|
|
eurPrice REAL CHECK (eurPrice IS NULL OR eurPrice >= 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0),
|
|
type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest')),
|
|
source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')),
|
|
occurredAt TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK ((productId IS NULL) = (productCount IS NULL)),
|
|
CHECK (type NOT IN ('buy','sell') OR eurPrice IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO Event (id, assetId, fromAccountId, toAccountId, productId, productCount,
|
|
importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee,
|
|
type, source, occurredAt, createdAt)
|
|
SELECT id, assetId, fromAccountId, toAccountId, productId, productCount,
|
|
importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee,
|
|
type, source, occurredAt, createdAt FROM EventOud`);
|
|
run(`DROP TABLE EventOud`);
|
|
run(`CREATE INDEX IxEventAssetId ON Event(assetId)`);
|
|
run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`);
|
|
run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`);
|
|
run(`CREATE INDEX IxEventProductId ON Event(productId)`);
|
|
run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`);
|
|
run(`CREATE INDEX IxEventType ON Event(type)`);
|
|
run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`);
|
|
run(`CREATE INDEX IxEventImportReference ON Event(importReference)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%EventOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 68 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The rebuild rewrote the foreign keys of other tables; the step was rolled back ` +
|
|
`and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
69: () => {
|
|
run(`DELETE FROM Notification WHERE kind = 'ai_command'`);
|
|
run(`ALTER TABLE Notification RENAME TO NotificationOud`);
|
|
run(`CREATE TABLE Notification (
|
|
id INTEGER PRIMARY KEY,
|
|
count INTEGER NOT NULL CHECK (count >= 0),
|
|
highWater INTEGER NOT NULL CHECK (highWater >= 0),
|
|
kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_proposal')),
|
|
dismissedAt TEXT,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`INSERT INTO Notification (id, count, highWater, kind, dismissedAt, createdAt)
|
|
SELECT id, count, highWater, kind, dismissedAt, createdAt FROM NotificationOud`);
|
|
run(`DROP TABLE NotificationOud`);
|
|
run(`CREATE INDEX IxNotificationKind ON Notification(kind)`);
|
|
},
|
|
|
|
70: () => {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Asset RENAME TO AssetOud`);
|
|
run(`CREATE TABLE Asset (
|
|
id INTEGER PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
unit TEXT NOT NULL,
|
|
subunit TEXT,
|
|
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
|
|
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
|
|
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
|
|
UNIQUE (symbol),
|
|
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind)
|
|
SELECT id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind FROM AssetOud`);
|
|
run(`DROP TABLE AssetOud`);
|
|
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 70 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The rebuild rewrote the foreign keys of other tables; the step was rolled back ` +
|
|
`and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
71: () => {
|
|
run(`CREATE TABLE ProviderFixedRate (
|
|
providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE,
|
|
eur REAL NOT NULL CHECK (eur > 0)
|
|
) STRICT;`);
|
|
const nu = new Date().toISOString();
|
|
if (!one(`SELECT 1 AS x FROM Provider WHERE key = 'fixed'`)) {
|
|
run(`INSERT INTO Provider
|
|
(key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName,
|
|
authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
|
|
VALUES ('fixed', 'Vaste koers', 'vaste koers', '', NULL, NULL, 'none', 0, 1, 1, 1, ?)`, [nu]);
|
|
const providerId = one(`SELECT id FROM Provider WHERE key = 'fixed'`).id;
|
|
run(`INSERT INTO ProviderFixedRate (providerId, eur) VALUES (?, 1.0)`, [providerId]);
|
|
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'EUR')`, [providerId]);
|
|
for (const capability of ['spot', 'history_point']) {
|
|
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate)
|
|
VALUES (?, ?, 'GET', '', NULL)`, [providerId, capability]);
|
|
}
|
|
}
|
|
if (!one(`SELECT 1 AS x FROM Asset WHERE symbol = 'EUR'`)) {
|
|
run(`INSERT INTO Asset (symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind)
|
|
VALUES ('EUR', 'Euro', 'euro', NULL, 'EUR', 'cent', 'unit', 2, 'fiat')`);
|
|
}
|
|
const assetId = one(`SELECT id FROM Asset WHERE symbol = 'EUR'`).id;
|
|
const providerId = one(`SELECT id FROM Provider WHERE key = 'fixed'`).id;
|
|
for (const [consumerKey, capability] of [
|
|
['converter', 'spot'],
|
|
['fiat', 'spot'], ['fiat', 'history_point'],
|
|
['tax', 'spot'], ['tax', 'history_point'],
|
|
['valuations', 'history_point'],
|
|
]) {
|
|
if (one(`SELECT 1 AS x FROM ConsumerSource WHERE consumerKey = ? AND assetId = ? AND capability = ?`,
|
|
[consumerKey, assetId, capability])) continue;
|
|
run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status)
|
|
VALUES (?, ?, ?, ?, 1, 'confirmed')`, [consumerKey, assetId, capability, providerId]);
|
|
}
|
|
},
|
|
|
|
72: () => {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Asset RENAME TO AssetOud`);
|
|
run(`CREATE TABLE Asset (
|
|
id INTEGER PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
unit TEXT NOT NULL,
|
|
subunit TEXT,
|
|
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
|
|
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
|
|
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
|
|
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')),
|
|
UNIQUE (symbol),
|
|
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO Asset (id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind)
|
|
SELECT id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind FROM AssetOud`);
|
|
run(`DROP TABLE AssetOud`);
|
|
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
|
|
|
|
run(`ALTER TABLE Account RENAME TO AccountOud`);
|
|
run(`CREATE TABLE Account (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
|
|
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)),
|
|
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
|
|
closedAt TEXT,
|
|
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')),
|
|
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
|
|
) STRICT;`);
|
|
run(`INSERT INTO Account (id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt)
|
|
SELECT id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt FROM AccountOud`);
|
|
run(`DROP TABLE AccountOud`);
|
|
run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%AccountOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 72 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The rebuild rewrote the foreign keys of other tables; the step was rolled back ` +
|
|
`and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
73: () => {
|
|
run(`DELETE FROM DashboardWidget WHERE type = 'dbstatus'`);
|
|
},
|
|
|
|
74: () => {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Valuation RENAME TO ValuationOud`);
|
|
run(`CREATE TABLE Valuation (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
balance INTEGER NOT NULL,
|
|
eurRate REAL NOT NULL,
|
|
source TEXT NOT NULL CHECK (source IN ('onchain','manual','statement')),
|
|
valuedOn TEXT NOT NULL,
|
|
rateAt TEXT NOT NULL,
|
|
gapAcceptedAt TEXT,
|
|
UNIQUE (assetId, valuedOn, accountId)
|
|
) STRICT;`);
|
|
run(`INSERT INTO Valuation (id, assetId, accountId, balance, eurRate, source, valuedOn, rateAt)
|
|
SELECT id, assetId, accountId, balance, eurRate, source, valuedOn, rateAt FROM ValuationOud`);
|
|
run(`DROP TABLE ValuationOud`);
|
|
run(`CREATE INDEX IxValuationAccountId ON Valuation(accountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%ValuationOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 74 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
75: () => {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE InterestSchedule RENAME TO InterestScheduleOud`);
|
|
run(`CREATE TABLE InterestSchedule (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION,
|
|
payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
note TEXT,
|
|
principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0),
|
|
annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0),
|
|
rateKind TEXT NOT NULL CHECK (rateKind IN ('fixed','variable')),
|
|
creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN ('term_end','annual','quarterly','monthly','weekly')),
|
|
creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6),
|
|
creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31),
|
|
startsOn TEXT NOT NULL,
|
|
endsOn TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)),
|
|
CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'),
|
|
CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'),
|
|
CHECK (endsOn IS NULL OR endsOn >= startsOn)
|
|
) STRICT;`);
|
|
run(`INSERT INTO InterestSchedule (id, accountId, principalAssetId, payoutAssetId, note, principalAmount,
|
|
annualRate, rateKind, creditCadence, creditWeekday, creditDayOfMonth,
|
|
startsOn, endsOn, createdAt)
|
|
SELECT id, accountId, principalAssetId, payoutAssetId, note, principalAmount,
|
|
annualRate, rateKind, creditCadence, creditWeekday, creditDayOfMonth,
|
|
startsOn, endsOn, createdAt FROM InterestScheduleOud`);
|
|
run(`DROP TABLE InterestScheduleOud`);
|
|
run(`CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)`);
|
|
run(`CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)`);
|
|
run(`CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%InterestScheduleOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 75 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
76: () => {
|
|
run(`CREATE TABLE InterestPeriod (
|
|
id INTEGER PRIMARY KEY,
|
|
scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE,
|
|
startsOn TEXT NOT NULL,
|
|
annualRate REAL NOT NULL CHECK (annualRate >= 0),
|
|
UNIQUE (scheduleId, startsOn)
|
|
) STRICT;`);
|
|
},
|
|
|
|
77: () => {
|
|
if (!tableExists('Preference')) return;
|
|
const uit = one(`SELECT value AS v FROM Preference WHERE scope = 'ai' AND key = 'enabled'`);
|
|
run(`UPDATE Preference SET key = 'allow_read' WHERE scope = 'ai' AND key = 'enabled'`);
|
|
if (uit && Number(uit.v) === 0) {
|
|
for (const sleutel of ['allow_write', 'allow_control']) {
|
|
run(`INSERT INTO Preference (scope, key, value) VALUES ('ai', ?, '0')
|
|
ON CONFLICT (scope, key) DO UPDATE SET value = '0'`, [sleutel]);
|
|
}
|
|
}
|
|
},
|
|
|
|
78: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
observedAt TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, kind, eur, observedAt, note, status, detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, kind, eur, observedAt, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 78 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
79: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
observedAt TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, fromAccountId, toAccountId, kind, name, accountKind,
|
|
isOwnCustody, eventType, amount, eur, observedAt, note, status,
|
|
detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, fromAccountId, toAccountId, kind, name, accountKind,
|
|
isOwnCustody, eventType, amount, eur, observedAt, note, status,
|
|
detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 79 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
80: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
observedAt TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, observedAt, note,
|
|
status, detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, observedAt, note,
|
|
status, detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 80 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
81: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, observedAt,
|
|
note, status, detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, observedAt,
|
|
note, status, detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 81 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
82: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL),
|
|
CHECK (kind <> 'portfolio' OR name IS NOT NULL)
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, note, status, detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 82 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
83: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
scope TEXT,
|
|
key TEXT,
|
|
value TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL),
|
|
CHECK (kind <> 'portfolio' OR name IS NOT NULL),
|
|
CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)),
|
|
CHECK (kind = 'preference' OR (scope IS NULL AND key IS NULL AND value IS NULL))
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, note, status, detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 83 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
84: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL,
|
|
createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL,
|
|
createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
scope TEXT,
|
|
key TEXT,
|
|
value TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL),
|
|
CHECK (kind <> 'portfolio' OR name IS NOT NULL),
|
|
CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)),
|
|
CHECK (kind = 'preference' OR (scope IS NULL AND key IS NULL AND value IS NULL))
|
|
) STRICT;`);
|
|
run(`INSERT INTO AiProposal (id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt)
|
|
SELECT id, assetId, accountId, fromAccountId, toAccountId, kind, name,
|
|
accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 84 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
85: () => {
|
|
const TIJDSTEMPELS = {
|
|
createdAt: [
|
|
'Asset', 'Account', 'Label', 'MetalProduct', 'Valuation', 'InterestPeriod', 'WalletAddress',
|
|
'ConsumerSource', 'DashboardWidget', 'Preference', 'ProviderAuthenticationHmac',
|
|
'ProviderEndpoint', 'ProviderFixedRate', 'ProviderInputMap', 'ProviderOutputMap',
|
|
'ProviderCoverage',
|
|
],
|
|
updatedAt: [
|
|
'Account', 'Asset', 'Event', 'Valuation', 'Label', 'MetalProduct', 'InterestSchedule',
|
|
'InterestPeriod', 'InterestCredit', 'WalletSource',
|
|
],
|
|
};
|
|
for (const [kolom, tabellen] of Object.entries(TIJDSTEMPELS)) {
|
|
for (const tabel of tabellen) {
|
|
if (!tableExists(tabel)) continue;
|
|
if (columnExists(tabel, kolom)) continue;
|
|
run(`ALTER TABLE ${tabel} ADD COLUMN ${kolom} TEXT`);
|
|
}
|
|
}
|
|
},
|
|
|
|
86: () => {
|
|
if (!tableExists('PriceHistory')) return;
|
|
if (columnExists('PriceHistory', 'createdAt')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE PriceHistory RENAME TO PriceHistoryOud`);
|
|
run(`CREATE TABLE PriceHistory (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
|
|
eur REAL,
|
|
usd REAL,
|
|
resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')),
|
|
observedAt TEXT NOT NULL,
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
UNIQUE (assetId, providerId, observedAt, resolution),
|
|
CHECK (eur IS NOT NULL OR usd IS NOT NULL)
|
|
) STRICT`);
|
|
run(`INSERT INTO PriceHistory (id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt)
|
|
SELECT id, assetId, providerId, eur, usd, resolution, observedAt, updatedAt FROM PriceHistoryOud`);
|
|
run(`DROP TABLE PriceHistoryOud`);
|
|
run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`);
|
|
run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%PriceHistoryOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 86 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
87: () => {
|
|
if (tableExists('ChangeLog')) return;
|
|
run(`CREATE TABLE ChangeLog (
|
|
id INTEGER PRIMARY KEY,
|
|
tableName TEXT NOT NULL,
|
|
rowId INTEGER NOT NULL,
|
|
operation TEXT NOT NULL CHECK (operation IN ('update','delete')),
|
|
origin TEXT NOT NULL CHECK (origin IN ('manual','import','reconcile','interest','lightning','ai_proposal','merge','system')),
|
|
originId INTEGER,
|
|
changes TEXT NOT NULL,
|
|
occurredAt TEXT NOT NULL
|
|
) STRICT`);
|
|
run(`CREATE INDEX IxChangeLogTableNameRowId ON ChangeLog(tableName, rowId)`);
|
|
run(`CREATE INDEX IxChangeLogOccurredAt ON ChangeLog(occurredAt)`);
|
|
},
|
|
|
|
88: () => {
|
|
if (!tableExists('Event')) return;
|
|
if (columnExists('Event', 'priceOrigin')) return;
|
|
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Event RENAME TO EventOud`);
|
|
run(`CREATE TABLE Event (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION,
|
|
productCount INTEGER CHECK (productCount IS NULL OR productCount > 0),
|
|
importReference TEXT,
|
|
externalAddress TEXT,
|
|
note TEXT,
|
|
amount INTEGER NOT NULL CHECK (amount > 0),
|
|
eurPrice REAL CHECK (eurPrice IS NULL OR eurPrice >= 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0),
|
|
priceOrigin TEXT CHECK (priceOrigin IS NULL OR priceOrigin IN ('paid','market_day','market_moment','none')),
|
|
type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest')),
|
|
source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')),
|
|
occurredAt TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT,
|
|
CHECK ((productId IS NULL) = (productCount IS NULL)),
|
|
CHECK (type NOT IN ('buy','sell') OR eurPrice IS NOT NULL)
|
|
) STRICT`);
|
|
run(`INSERT INTO Event (id, assetId, fromAccountId, toAccountId, productId, productCount,
|
|
importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee,
|
|
type, source, occurredAt, createdAt, updatedAt)
|
|
SELECT id, assetId, fromAccountId, toAccountId, productId, productCount,
|
|
importReference, externalAddress, note, amount, eurPrice, eurFee, unitFee,
|
|
type, source, occurredAt, createdAt, updatedAt FROM EventOud`);
|
|
run(`DROP TABLE EventOud`);
|
|
run(`CREATE INDEX IxEventAssetId ON Event(assetId)`);
|
|
run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`);
|
|
run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`);
|
|
run(`CREATE INDEX IxEventProductId ON Event(productId)`);
|
|
run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`);
|
|
run(`CREATE INDEX IxEventType ON Event(type)`);
|
|
run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`);
|
|
run(`CREATE INDEX IxEventImportReference ON Event(importReference)`);
|
|
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%EventOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 88 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
89: () => {
|
|
if (tableExists('HygieneAcceptance')) return;
|
|
run(`CREATE TABLE HygieneAcceptance (
|
|
id INTEGER PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
fingerprint TEXT NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
note TEXT,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT`);
|
|
run(`CREATE UNIQUE INDEX UxHygieneAcceptanceFingerprint ON HygieneAcceptance(fingerprint)`);
|
|
run(`CREATE INDEX IxHygieneAcceptanceKind ON HygieneAcceptance(kind)`);
|
|
},
|
|
|
|
90: () => {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
if (tableExists('ProviderEndpoint') && !columnExists('ProviderEndpoint', 'quoteCurrency')) {
|
|
const metCreatedAt = columnExists('ProviderEndpoint', 'createdAt');
|
|
run(`ALTER TABLE ProviderEndpoint RENAME TO ProviderEndpointOud`);
|
|
run(`CREATE TABLE ProviderEndpoint (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
pathTemplate TEXT NOT NULL,
|
|
queryTemplate TEXT,
|
|
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
|
|
method TEXT NOT NULL CHECK (method IN ('GET','POST')),
|
|
quoteCurrency TEXT,
|
|
createdAt TEXT,
|
|
UNIQUE (providerId, capability)
|
|
) STRICT`);
|
|
const kolommen = `id, providerId, pathTemplate, queryTemplate, capability, method${metCreatedAt ? ', createdAt' : ''}`;
|
|
run(`INSERT INTO ProviderEndpoint (${kolommen}) SELECT ${kolommen} FROM ProviderEndpointOud`);
|
|
run(`DROP TABLE ProviderEndpointOud`);
|
|
}
|
|
const outputMapSql = one(`SELECT sql AS s FROM sqlite_master WHERE type = 'table' AND name = 'ProviderOutputMap'`);
|
|
if (outputMapSql && !String(outputMapSql.s).includes('iso_date_to_s')) {
|
|
run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapOud`);
|
|
run(`CREATE TABLE ProviderOutputMap (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
jsonPath TEXT NOT NULL,
|
|
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
|
|
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
|
|
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s')),
|
|
createdAt TEXT
|
|
) STRICT`);
|
|
run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform, createdAt)
|
|
SELECT id, providerId, jsonPath, capability, internalField, transform, createdAt FROM ProviderOutputMapOud`);
|
|
run(`DROP TABLE ProviderOutputMapOud`);
|
|
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
|
|
}
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%ProviderEndpointOud%' OR sql LIKE '%ProviderOutputMapOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 90 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
91: () => {
|
|
for (const t of ['Provider', 'ProviderCoverage', 'ProviderEndpoint', 'ProviderOutputMap', 'Asset', 'ConsumerSource']) {
|
|
if (!tableExists(t)) return;
|
|
}
|
|
const nu = new Date().toISOString();
|
|
if (!one(`SELECT 1 AS x FROM Provider WHERE key = 'frankfurter'`)) {
|
|
run(`INSERT INTO Provider
|
|
(key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName,
|
|
authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
|
|
VALUES ('frankfurter', 'ECB-wisselkoersen (Frankfurter)', 'ecb-wisselkoersen (frankfurter)',
|
|
'https://api.frankfurter.dev', NULL, NULL, 'none', 0, 1, 1, 0, ?)`, [nu]);
|
|
const providerId = one(`SELECT id FROM Provider WHERE key = 'frankfurter'`).id;
|
|
run(`INSERT INTO ProviderCoverage (providerId, symbol) VALUES (?, 'USD')`, [providerId]);
|
|
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, createdAt)
|
|
VALUES (?, 'spot', 'GET', '/v1/latest', 'base={symbol}&symbols=EUR', NULL, ?)`, [providerId, nu]);
|
|
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, createdAt)
|
|
VALUES (?, 'daily_history', 'GET', '/v1/1999-01-04..', 'base={symbol}&symbols=EUR', NULL, ?)`, [providerId, nu]);
|
|
for (const [capability, internalField, jsonPath, transform] of [
|
|
['spot', 'EUR', 'rates.EUR', 'to_number'],
|
|
['daily_history', 'time', 'rates{}.@key', 'iso_date_to_s'],
|
|
['daily_history', 'EUR', 'rates{}.EUR', 'to_number'],
|
|
]) {
|
|
run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform, createdAt)
|
|
VALUES (?, ?, ?, ?, ?, ?)`, [providerId, capability, internalField, jsonPath, transform, nu]);
|
|
}
|
|
}
|
|
if (!one(`SELECT 1 AS x FROM Asset WHERE symbol = 'USD'`)) {
|
|
run(`INSERT INTO Asset (symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind)
|
|
VALUES ('USD', 'Amerikaanse dollar', 'amerikaanse dollar', NULL, 'USD', 'cent', 'unit', 2, 'fiat')`);
|
|
}
|
|
const assetId = one(`SELECT id FROM Asset WHERE symbol = 'USD'`).id;
|
|
const providerId = one(`SELECT id FROM Provider WHERE key = 'frankfurter'`).id;
|
|
for (const [consumerKey, capability] of [
|
|
['converter', 'spot'],
|
|
['fiat', 'spot'], ['fiat', 'daily_history'],
|
|
['signals', 'daily_history'],
|
|
['tax', 'spot'], ['tax', 'daily_history'],
|
|
['valuations', 'daily_history'],
|
|
]) {
|
|
if (one(`SELECT 1 AS x FROM ConsumerSource WHERE consumerKey = ? AND assetId = ? AND capability = ?`,
|
|
[consumerKey, assetId, capability])) continue;
|
|
run(`INSERT INTO ConsumerSource (consumerKey, assetId, capability, providerId, rank, status)
|
|
VALUES (?, ?, ?, ?, 1, 'confirmed')`, [consumerKey, assetId, capability, providerId]);
|
|
}
|
|
},
|
|
|
|
92: () => {
|
|
for (const t of ['Provider', 'ProviderEndpoint', 'ProviderOutputMap', 'ConsumerSource']) {
|
|
if (!tableExists(t)) return;
|
|
}
|
|
const nu = new Date().toISOString();
|
|
for (const [key, path, usdTransform] of [
|
|
['eulerpool', '/api/1/crypto/quotes/{symbol}', 'last_per_day'],
|
|
['eulerpool_metal', '/api/1/commodity/quotes/{symbol}', 'troy_ounce_to_gram'],
|
|
]) {
|
|
const p = one(`SELECT id FROM Provider WHERE key = ?`, [key]);
|
|
if (!p) continue;
|
|
run(`DELETE FROM ConsumerSource WHERE providerId = ? AND capability <> 'daily_history'`, [p.id]);
|
|
run(`DELETE FROM ProviderEndpoint WHERE providerId = ?`, [p.id]);
|
|
run(`DELETE FROM ProviderOutputMap WHERE providerId = ?`, [p.id]);
|
|
run(`INSERT INTO ProviderEndpoint (providerId, capability, method, pathTemplate, queryTemplate, quoteCurrency, createdAt)
|
|
VALUES (?, 'daily_history', 'GET', ?, NULL, 'USD', ?)`, [p.id, path, nu]);
|
|
run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform, createdAt)
|
|
VALUES (?, 'daily_history', 'time', '[].timestamp', 'ms_to_s', ?)`, [p.id, nu]);
|
|
run(`INSERT INTO ProviderOutputMap (providerId, capability, internalField, jsonPath, transform, createdAt)
|
|
VALUES (?, 'daily_history', 'USD', '[].price', ?, ?)`, [p.id, usdTransform, nu]);
|
|
}
|
|
},
|
|
|
|
93: () => {
|
|
if (!tableExists('ProviderCallLimit')) {
|
|
run(`CREATE TABLE ProviderCallLimit (
|
|
providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE,
|
|
callsPerMonth INTEGER NOT NULL CHECK (callsPerMonth > 0),
|
|
createdAt TEXT
|
|
) STRICT`);
|
|
}
|
|
if (!tableExists('Provider') || !columnExists('Provider', 'key')) return;
|
|
const nu = new Date().toISOString();
|
|
for (const [key, calls] of [['goldapi', 100], ['metalsdev', 100], ['eulerpool', 1000], ['eulerpool_metal', 1000], ['coingecko', 10000]]) {
|
|
const p = one(`SELECT id FROM Provider WHERE key = ?`, [key]);
|
|
if (!p || one(`SELECT 1 AS x FROM ProviderCallLimit WHERE providerId = ?`, [p.id])) continue;
|
|
run(`INSERT INTO ProviderCallLimit (providerId, callsPerMonth, createdAt) VALUES (?, ?, ?)`, [p.id, calls, nu]);
|
|
}
|
|
},
|
|
|
|
94: () => {
|
|
if (tableExists('IgnoredPriceGap')) return;
|
|
run(`CREATE TABLE IgnoredPriceGap (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
fromDay TEXT NOT NULL,
|
|
untilDay TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (fromDay <= untilDay)
|
|
) STRICT`);
|
|
run(`CREATE INDEX IxIgnoredPriceGapAssetId ON IgnoredPriceGap(assetId)`);
|
|
},
|
|
|
|
95: () => {
|
|
if (!tableExists('ProviderCallLimit') || !tableExists('Provider') || !columnExists('Provider', 'key')) return;
|
|
run(`UPDATE ProviderCallLimit SET callsPerMonth = 100000
|
|
WHERE callsPerMonth = 1000
|
|
AND providerId IN (SELECT id FROM Provider WHERE key IN ('eulerpool', 'eulerpool_metal'))`);
|
|
},
|
|
|
|
96: () => {
|
|
if (!tableExists('ProviderEndpoint') || columnExists('ProviderEndpoint', 'minIntervalSeconds')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
const metCreatedAt = columnExists('ProviderEndpoint', 'createdAt');
|
|
const metMunt = columnExists('ProviderEndpoint', 'quoteCurrency');
|
|
run(`ALTER TABLE ProviderEndpoint RENAME TO ProviderEndpointOud`);
|
|
run(`CREATE TABLE ProviderEndpoint (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
pathTemplate TEXT NOT NULL,
|
|
queryTemplate TEXT,
|
|
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
|
|
method TEXT NOT NULL CHECK (method IN ('GET','POST')),
|
|
quoteCurrency TEXT,
|
|
minIntervalSeconds INTEGER CHECK (minIntervalSeconds IS NULL OR minIntervalSeconds > 0),
|
|
createdAt TEXT,
|
|
UNIQUE (providerId, capability)
|
|
) STRICT`);
|
|
const kolommen = `id, providerId, pathTemplate, queryTemplate, capability, method`
|
|
+ `${metMunt ? ', quoteCurrency' : ''}${metCreatedAt ? ', createdAt' : ''}`;
|
|
run(`INSERT INTO ProviderEndpoint (${kolommen}) SELECT ${kolommen} FROM ProviderEndpointOud`);
|
|
run(`DROP TABLE ProviderEndpointOud`);
|
|
const besmet = all(`SELECT name FROM sqlite_master WHERE sql LIKE '%ProviderEndpointOud%'`);
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 96 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
97: () => {
|
|
if (!tableExists('ConsumerSource') || tableExists('PriceSource')) return;
|
|
const KEUZE = { spot: 'spot', daily_history: 'history', history_point: 'history' };
|
|
const GROEP = { spot: 0, daily_history: 0, history_point: 1 };
|
|
const STERKTE = { proposed: 0, confirmed: 1, manual: 2 };
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`CREATE TABLE PriceSource (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
|
|
capability TEXT NOT NULL CHECK (capability IN ('spot','history')),
|
|
rank INTEGER CHECK (rank IS NULL OR rank > 0),
|
|
status TEXT NOT NULL CHECK (status IN ('proposed','confirmed','manual')),
|
|
createdAt TEXT,
|
|
UNIQUE (assetId, capability, providerId),
|
|
UNIQUE (assetId, capability, rank)
|
|
) STRICT`);
|
|
run(`CREATE INDEX IxPriceSourceProviderId ON PriceSource(providerId)`);
|
|
const metCreatedAt = columnExists('ConsumerSource', 'createdAt');
|
|
const rijen = all(`SELECT assetId, capability, providerId, rank, status${metCreatedAt ? ', createdAt' : ''}
|
|
FROM ConsumerSource ORDER BY id`);
|
|
const cellen = new Map();
|
|
for (const r of rijen) {
|
|
const keuze = KEUZE[r.capability];
|
|
if (!keuze) continue;
|
|
const k = `${r.assetId}|${keuze}`;
|
|
if (!cellen.has(k)) cellen.set(k, new Map());
|
|
const perBron = cellen.get(k);
|
|
const c = perBron.get(r.providerId)
|
|
|| { assetId: r.assetId, keuze, providerId: r.providerId, groep: GROEP[r.capability], rank: null, status: r.status, createdAt: null };
|
|
const g = GROEP[r.capability];
|
|
if (r.rank != null && (c.rank == null || g < c.groep || (g === c.groep && r.rank < c.rank))) {
|
|
c.groep = g;
|
|
c.rank = r.rank;
|
|
}
|
|
if (STERKTE[r.status] > STERKTE[c.status]) c.status = r.status;
|
|
const t = r.createdAt ?? null;
|
|
if (t && (!c.createdAt || t < c.createdAt)) c.createdAt = t;
|
|
perBron.set(r.providerId, c);
|
|
}
|
|
for (const perBron of cellen.values()) {
|
|
const gerangschikt = [...perBron.values()].filter(c => c.rank != null)
|
|
.sort((a, b) => a.groep - b.groep || a.rank - b.rank || STERKTE[b.status] - STERKTE[a.status] || a.providerId - b.providerId);
|
|
gerangschikt.forEach((c, i) => { c.rank = i + 1; });
|
|
for (const c of perBron.values()) {
|
|
run(`INSERT INTO PriceSource (assetId, capability, providerId, rank, status, createdAt) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[c.assetId, c.keuze, c.providerId, c.rank, c.status, c.createdAt]);
|
|
}
|
|
}
|
|
run(`DROP TABLE ConsumerSource`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ConsumerSource%'`)
|
|
.filter(r => /\bConsumerSource\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 97 aborted: ${besmet.map(r => r.name).join(', ')} still refers to ConsumerSource. ` +
|
|
`The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
}
|
|
},
|
|
|
|
98: () => {
|
|
if (!tableExists('AiProposal')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL,
|
|
createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL,
|
|
createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference','source_choice')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
eur REAL CHECK (eur IS NULL OR eur > 0),
|
|
eurFee REAL CHECK (eurFee IS NULL OR eurFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
scope TEXT,
|
|
key TEXT,
|
|
value TEXT,
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND eur IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR eur IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL),
|
|
CHECK (kind <> 'portfolio' OR name IS NOT NULL),
|
|
CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)),
|
|
CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN ('spot','history')
|
|
AND key IS NOT NULL AND value IN ('first','remove'))),
|
|
CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL))
|
|
) STRICT;`);
|
|
const kolommen = `id, assetId, accountId, fromAccountId, toAccountId, createdEventId, createdAccountId,
|
|
createdValuationId, kind, name, accountKind, isOwnCustody, eventType, amount, eur, eurFee, balance,
|
|
observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt`;
|
|
run(`INSERT INTO AiProposal (${kolommen}) SELECT ${kolommen} FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`)
|
|
.filter(r => /\bAiProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 98 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
99: () => {
|
|
if (!tableExists('ProviderInputMap')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
if (!columnExists('ProviderInputMap', 'quoteUnit')) {
|
|
const metCreatedAt = columnExists('ProviderInputMap', 'createdAt');
|
|
run(`ALTER TABLE ProviderInputMap RENAME TO ProviderInputMapOud`);
|
|
run(`CREATE TABLE ProviderInputMap (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
symbol TEXT NOT NULL,
|
|
externalId TEXT NOT NULL,
|
|
quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN ('ozt')),
|
|
createdAt TEXT,
|
|
UNIQUE (providerId, symbol)
|
|
) STRICT`);
|
|
const kolommen = `id, providerId, symbol, externalId${metCreatedAt ? ', createdAt' : ''}`;
|
|
run(`INSERT INTO ProviderInputMap (${kolommen}) SELECT ${kolommen} FROM ProviderInputMapOud`);
|
|
run(`DROP TABLE ProviderInputMapOud`);
|
|
}
|
|
const bron = key => (tableExists('Provider') ? one(`SELECT id FROM Provider WHERE key = ?`, [key]) : null);
|
|
const crypto = bron('eulerpool'), metaal = bron('eulerpool_metal');
|
|
if (crypto && metaal) {
|
|
const e = crypto.id, m = metaal.id, nu = new Date().toISOString();
|
|
run(`UPDATE ProviderEndpoint SET pathTemplate = '/api/1/{external_id}/quotes/{symbol}'
|
|
WHERE providerId = ? AND capability = 'daily_history'`, [e]);
|
|
for (const r of all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [e])) {
|
|
run(`INSERT OR IGNORE INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, createdAt)
|
|
VALUES (?, ?, 'crypto', NULL, ?)`, [e, r.symbol, nu]);
|
|
}
|
|
for (const r of all(`SELECT symbol FROM ProviderCoverage WHERE providerId = ?`, [m])) {
|
|
run(`INSERT OR IGNORE INTO ProviderCoverage (providerId, symbol) VALUES (?, ?)`, [e, r.symbol]);
|
|
run(`INSERT OR IGNORE INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, createdAt)
|
|
VALUES (?, ?, 'commodity', 'ozt', ?)`, [e, r.symbol, nu]);
|
|
}
|
|
if (tableExists('PriceSource')) {
|
|
run(`UPDATE OR IGNORE PriceSource SET providerId = ? WHERE providerId = ?`, [e, m]);
|
|
run(`DELETE FROM PriceSource WHERE providerId = ?`, [m]);
|
|
}
|
|
if (tableExists('PriceHistory')) {
|
|
run(`UPDATE OR IGNORE PriceHistory SET providerId = ? WHERE providerId = ?`, [e, m]);
|
|
run(`DELETE FROM PriceHistory WHERE providerId = ?`, [m]);
|
|
}
|
|
if (tableExists('ApiCallMetric')) {
|
|
run(`INSERT INTO ApiCallMetric (providerId, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs)
|
|
SELECT ?, capability, bucketMinute, okCount, failCount, sumLatencyMs, maxLatencyMs FROM ApiCallMetric WHERE providerId = ?
|
|
ON CONFLICT (providerId, capability, bucketMinute) DO UPDATE SET
|
|
okCount = okCount + excluded.okCount, failCount = failCount + excluded.failCount,
|
|
sumLatencyMs = sumLatencyMs + excluded.sumLatencyMs,
|
|
maxLatencyMs = MAX(maxLatencyMs, excluded.maxLatencyMs)`, [e, m]);
|
|
run(`DELETE FROM ApiCallMetric WHERE providerId = ?`, [m]);
|
|
}
|
|
for (const t of ['ProviderEndpoint', 'ProviderCoverage', 'ProviderInputMap', 'ProviderOutputMap',
|
|
'ProviderCallLimit', 'ProviderAuthenticationHmac', 'ProviderFixedRate']) {
|
|
if (tableExists(t)) run(`DELETE FROM ${t} WHERE providerId = ?`, [m]);
|
|
}
|
|
run(`DELETE FROM Provider WHERE id = ?`, [m]);
|
|
}
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ProviderInputMapOud%'`)
|
|
.filter(r => /\bProviderInputMapOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 99 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
100: () => {
|
|
if (!tableExists('PriceHistory')) return;
|
|
const usd = columnExists('PriceHistory', 'usd');
|
|
const geenKoers = kolom => `(${kolom} IS NULL OR ${kolom} <= 0)`;
|
|
run(`DELETE FROM PriceHistory WHERE ${geenKoers('eur')}${usd ? ` AND ${geenKoers('usd')}` : ''}`);
|
|
run(`UPDATE PriceHistory SET eur = NULL WHERE eur <= 0`);
|
|
if (usd) run(`UPDATE PriceHistory SET usd = NULL WHERE usd <= 0`);
|
|
},
|
|
|
|
101: () => {
|
|
if (tableExists('AssetNote')) return;
|
|
run(`CREATE TABLE AssetNote (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
body TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT`);
|
|
run(`CREATE UNIQUE INDEX UxAssetNoteAssetId ON AssetNote(assetId)`);
|
|
},
|
|
|
|
102: () => {
|
|
if (!tableExists('ProviderInputMap')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
if (!columnExists('ProviderInputMap', 'isCatchAll')) {
|
|
const metQuoteUnit = columnExists('ProviderInputMap', 'quoteUnit');
|
|
const metCreatedAt = columnExists('ProviderInputMap', 'createdAt');
|
|
run(`ALTER TABLE ProviderInputMap RENAME TO ProviderInputMapOud`);
|
|
run(`CREATE TABLE ProviderInputMap (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
symbol TEXT,
|
|
externalId TEXT NOT NULL,
|
|
quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN ('ozt')),
|
|
isCatchAll INTEGER NOT NULL CHECK (isCatchAll IN (0,1)),
|
|
createdAt TEXT,
|
|
UNIQUE (providerId, symbol),
|
|
CHECK ((isCatchAll = 1) = (symbol IS NULL))
|
|
) STRICT`);
|
|
const kolommen = `id, providerId, symbol, externalId${metQuoteUnit ? ', quoteUnit' : ''}${metCreatedAt ? ', createdAt' : ''}`;
|
|
run(`INSERT INTO ProviderInputMap (${kolommen}, isCatchAll) SELECT ${kolommen}, 0 FROM ProviderInputMapOud`);
|
|
run(`DROP TABLE ProviderInputMapOud`);
|
|
run(`CREATE UNIQUE INDEX UxProviderInputMapCatchAll ON ProviderInputMap(providerId) WHERE isCatchAll = 1`);
|
|
}
|
|
const euler = tableExists('Provider') ? one(`SELECT id FROM Provider WHERE key = 'eulerpool'`) : null;
|
|
if (euler && !one(`SELECT id FROM ProviderInputMap WHERE providerId = ? AND isCatchAll = 1`, [euler.id])) {
|
|
const crypto = all(`SELECT id FROM ProviderInputMap
|
|
WHERE providerId = ? AND externalId = 'crypto' AND quoteUnit IS NULL`, [euler.id]);
|
|
if (crypto.length) {
|
|
run(`DELETE FROM ProviderInputMap WHERE providerId = ? AND externalId = 'crypto' AND quoteUnit IS NULL`, [euler.id]);
|
|
run(`INSERT INTO ProviderInputMap (providerId, symbol, externalId, quoteUnit, isCatchAll, createdAt)
|
|
VALUES (?, NULL, 'crypto', NULL, 1, ?)`, [euler.id, new Date().toISOString()]);
|
|
}
|
|
}
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ProviderInputMapOud%'`)
|
|
.filter(r => /\bProviderInputMapOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 102 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
103: () => {
|
|
const outputMapSql = one(`SELECT sql AS s FROM sqlite_master WHERE type = 'table' AND name = 'ProviderOutputMap'`);
|
|
if (!outputMapSql) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
if (columnExists('ProviderOutputMap', 'transform') && !String(outputMapSql.s).includes('ms_to_s_local_close')) {
|
|
run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapOud`);
|
|
run(`CREATE TABLE ProviderOutputMap (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
jsonPath TEXT NOT NULL,
|
|
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
|
|
internalField TEXT NOT NULL CHECK (internalField IN ('EUR','USD','time','price')),
|
|
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')),
|
|
createdAt TEXT
|
|
) STRICT`);
|
|
run(`INSERT INTO ProviderOutputMap (id, providerId, jsonPath, capability, internalField, transform, createdAt)
|
|
SELECT id, providerId, jsonPath, capability, internalField, transform, createdAt FROM ProviderOutputMapOud`);
|
|
run(`DROP TABLE ProviderOutputMapOud`);
|
|
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
|
|
}
|
|
const euler = tableExists('Provider') ? one(`SELECT id FROM Provider WHERE key = 'eulerpool'`) : null;
|
|
if (euler && columnExists('ProviderOutputMap', 'transform')) {
|
|
run(`UPDATE ProviderOutputMap SET transform = 'ms_to_s_local_close'
|
|
WHERE providerId = ? AND capability = 'daily_history' AND internalField = 'time' AND transform = 'ms_to_s'`,
|
|
[euler.id]);
|
|
if (tableExists('PriceHistory') && tableExists('Asset')) {
|
|
run(`DELETE FROM PriceHistory
|
|
WHERE providerId = ? AND assetId IN (SELECT id FROM Asset WHERE kind = 'metal')`, [euler.id]);
|
|
}
|
|
}
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ProviderOutputMapOud%'`)
|
|
.filter(r => /\bProviderOutputMapOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 103 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
104: () => {
|
|
if (tableExists('Portfolio')) return;
|
|
run(`CREATE TABLE Portfolio (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
baseCurrency TEXT NOT NULL CHECK (baseCurrency GLOB '[A-Z][A-Z][A-Z]'),
|
|
createdAt TEXT
|
|
) STRICT`);
|
|
run(`INSERT INTO Portfolio (id, baseCurrency, createdAt) VALUES (1, 'EUR', NULL)`);
|
|
run(`CREATE TRIGGER TrPortfolioUpdate BEFORE UPDATE ON Portfolio
|
|
BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END`);
|
|
run(`CREATE TRIGGER TrPortfolioDelete BEFORE DELETE ON Portfolio
|
|
BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END`);
|
|
},
|
|
|
|
105: () => {
|
|
for (const [table, oud, nieuw] of [
|
|
['Event', 'eurPrice', 'fiatPrice'], ['Event', 'eurFee', 'fiatFee'],
|
|
['Valuation', 'eurRate', 'fiatRate'],
|
|
['PriceHistory', 'eur', 'fiatPrice'],
|
|
['ProviderFixedRate', 'eur', 'fiatPrice'],
|
|
['AiProposal', 'eur', 'fiatPrice'], ['AiProposal', 'eurFee', 'fiatFee'],
|
|
]) {
|
|
if (columnExists(table, oud)) run(`ALTER TABLE ${table} RENAME COLUMN ${oud} TO ${nieuw}`);
|
|
}
|
|
if (!tableExists('Preference')) return;
|
|
for (const [scope, oud, nieuw] of [
|
|
['table.trades', 'eurPrice', 'fiatPrice'], ['table.trades', 'eurFee', 'fiatFee'],
|
|
['table.valuations', 'eurRate', 'fiatRate'],
|
|
['table.prijsinfo', 'eur', 'fiatPrice'],
|
|
]) {
|
|
run(`UPDATE Preference SET value = json_set(value, '$.sort.key', ?)
|
|
WHERE scope = ? AND key = 'view'
|
|
AND CASE WHEN json_valid(value) THEN json_extract(value, '$.sort.key') END = ?`,
|
|
[nieuw, scope, oud]);
|
|
}
|
|
},
|
|
|
|
106: () => {
|
|
if (tableExists('Preference')) run(`DELETE FROM Preference WHERE scope = 'app' AND key = 'currency'`);
|
|
if (tableExists('AiProposal')) {
|
|
run(`UPDATE AiProposal SET status = 'rejected', resolvedAt = ?,
|
|
detail = 'The preference app.currency no longer exists: the app shows the unit of account.'
|
|
WHERE kind = 'preference' AND scope = 'app' AND key = 'currency' AND status = 'pending'`,
|
|
[new Date().toISOString()]);
|
|
}
|
|
},
|
|
|
|
107: () => {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
if (tableExists('ProviderOutputMap') && columnExists('ProviderOutputMap', 'internalField')
|
|
&& !one(`SELECT sql AS s FROM sqlite_master WHERE name = 'ProviderOutputMap'`).s.includes(`IN ('time','price')`)) {
|
|
const endpoints = tableExists('ProviderEndpoint') && columnExists('ProviderEndpoint', 'quoteCurrency');
|
|
const paren = all(`SELECT DISTINCT providerId, capability FROM ProviderOutputMap
|
|
WHERE internalField IN ('EUR','USD')`);
|
|
for (const { providerId, capability } of paren) {
|
|
const e = endpoints ? one(`SELECT quoteCurrency AS q FROM ProviderEndpoint WHERE providerId = ? AND capability = ?`,
|
|
[providerId, capability]) : null;
|
|
const q = e ? e.q : null;
|
|
const veld = !q || q === 'EUR' ? 'EUR' : q === 'USD' ? 'USD' : 'price';
|
|
const heeft = one(`SELECT id FROM ProviderOutputMap WHERE providerId = ? AND capability = ? AND internalField = ?`,
|
|
[providerId, capability, veld]);
|
|
if (heeft && veld !== 'price') {
|
|
run(`DELETE FROM ProviderOutputMap WHERE providerId = ? AND capability = ? AND internalField = 'price'`,
|
|
[providerId, capability]);
|
|
run(`UPDATE ProviderOutputMap SET internalField = 'price' WHERE id = ?`, [heeft.id]);
|
|
}
|
|
run(`DELETE FROM ProviderOutputMap WHERE providerId = ? AND capability = ? AND internalField IN ('EUR','USD')`,
|
|
[providerId, capability]);
|
|
}
|
|
const metCreatedAt = columnExists('ProviderOutputMap', 'createdAt');
|
|
run(`ALTER TABLE ProviderOutputMap RENAME TO ProviderOutputMapOud`);
|
|
run(`CREATE TABLE ProviderOutputMap (
|
|
id INTEGER PRIMARY KEY,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
jsonPath TEXT NOT NULL,
|
|
capability TEXT NOT NULL CHECK (capability IN ('spot','daily_history','history_point')),
|
|
internalField TEXT NOT NULL CHECK (internalField IN ('time','price')),
|
|
transform TEXT NOT NULL CHECK (transform IN ('none','ms_to_s','to_number','last_per_day','troy_ounce_to_gram','iso_date_to_s','ms_to_s_local_close')),
|
|
createdAt TEXT
|
|
) STRICT`);
|
|
const kolommen = `id, providerId, jsonPath, capability, internalField, transform${metCreatedAt ? ', createdAt' : ''}`;
|
|
run(`INSERT INTO ProviderOutputMap (${kolommen}) SELECT ${kolommen} FROM ProviderOutputMapOud`);
|
|
run(`DROP TABLE ProviderOutputMapOud`);
|
|
run(`CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability)`);
|
|
}
|
|
const volledig = ['assetId', 'providerId', 'resolution', 'observedAt'].every(k => columnExists('PriceHistory', k));
|
|
if (tableExists('PriceHistory') && columnExists('PriceHistory', 'usd') && volledig) {
|
|
run(`DELETE FROM PriceHistory WHERE fiatPrice IS NULL`);
|
|
const tijd = ['createdAt', 'updatedAt'].filter(k => columnExists('PriceHistory', k));
|
|
run(`ALTER TABLE PriceHistory RENAME TO PriceHistoryOud`);
|
|
run(`CREATE TABLE PriceHistory (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
|
|
fiatPrice REAL NOT NULL,
|
|
resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')),
|
|
observedAt TEXT NOT NULL,
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
UNIQUE (assetId, providerId, observedAt, resolution)
|
|
) STRICT`);
|
|
const kolommen = ['id', 'assetId', 'providerId', 'fiatPrice', 'resolution', 'observedAt', ...tijd].join(', ');
|
|
run(`INSERT INTO PriceHistory (${kolommen}) SELECT ${kolommen} FROM PriceHistoryOud`);
|
|
run(`DROP TABLE PriceHistoryOud`);
|
|
run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`);
|
|
run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`);
|
|
}
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%Oud%'`)
|
|
.filter(r => /\b(ProviderOutputMapOud|PriceHistoryOud)\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 107 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
108: () => {
|
|
if (!tableExists('AiProposal') || columnExists('AiProposal', 'currency')
|
|
|| !columnExists('AiProposal', 'fiatPrice')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL,
|
|
createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL,
|
|
createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference','source_choice')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice > 0),
|
|
fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
scope TEXT,
|
|
key TEXT,
|
|
value TEXT,
|
|
currency TEXT CHECK (currency IS NULL OR currency GLOB '[A-Z][A-Z][A-Z]'),
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND fiatPrice IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR fiatPrice IS NOT NULL),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL),
|
|
CHECK (kind <> 'portfolio' OR (name IS NOT NULL AND currency IS NOT NULL)),
|
|
CHECK (kind = 'portfolio' OR currency IS NULL),
|
|
CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)),
|
|
CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN ('spot','history')
|
|
AND key IS NOT NULL AND value IN ('first','remove'))),
|
|
CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL))
|
|
) STRICT;`);
|
|
const kolommen = `id, assetId, accountId, fromAccountId, toAccountId, createdEventId, createdAccountId,
|
|
createdValuationId, kind, name, accountKind, isOwnCustody, eventType, amount, fiatPrice, fiatFee, balance,
|
|
observedAt, scope, key, value, note, status, detail, resolvedAt, createdAt`;
|
|
run(`INSERT INTO AiProposal (${kolommen}, currency)
|
|
SELECT ${kolommen}, CASE kind WHEN 'portfolio' THEN 'EUR' END FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`)
|
|
.filter(r => /\bAiProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 108 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
109: () => {
|
|
if (!tableExists('Preference')) return;
|
|
run(`UPDATE Preference SET value = json_set(value, '$.sort.key', 'fiatTotal')
|
|
WHERE scope = 'table.trades' AND key = 'view'
|
|
AND CASE WHEN json_valid(value) THEN json_extract(value, '$.sort.key') END = 'eurTotal'`);
|
|
},
|
|
|
|
110: () => {
|
|
if (tableExists('PriceAbsence')) return;
|
|
run(`CREATE TABLE PriceAbsence (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
|
|
day TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT`);
|
|
run(`CREATE UNIQUE INDEX UxPriceAbsenceAssetIdProviderIdDay ON PriceAbsence(assetId, providerId, day)`);
|
|
run(`CREATE INDEX IxPriceAbsenceProviderId ON PriceAbsence(providerId)`);
|
|
},
|
|
|
|
111: () => {
|
|
if (!tableExists('Asset') || columnExists('Asset', 'balanceCapture')
|
|
|| !columnExists('Asset', 'taxCategory')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Asset RENAME TO AssetOud`);
|
|
run(`CREATE TABLE Asset (
|
|
id INTEGER PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
unit TEXT NOT NULL,
|
|
subunit TEXT,
|
|
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
|
|
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
|
|
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
|
|
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig','buiten')),
|
|
balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)),
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
UNIQUE (symbol),
|
|
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
|
|
) STRICT;`);
|
|
const kolommen = `id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind,
|
|
taxCategory, createdAt, updatedAt`;
|
|
run(`INSERT INTO Asset (${kolommen}) SELECT ${kolommen} FROM AssetOud`);
|
|
run(`DROP TABLE AssetOud`);
|
|
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AssetOud%'`)
|
|
.filter(r => /\bAssetOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 111 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
112: () => {
|
|
const asset = tableExists('Asset') && !columnExists('Asset', 'taxScope') && columnExists('Asset', 'balanceCapture');
|
|
const account = tableExists('Account') && !columnExists('Account', 'taxScope')
|
|
&& columnExists('Account', 'taxCategory') && columnExists('Account', 'createdAt');
|
|
if (!asset && !account) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
if (asset) {
|
|
run(`ALTER TABLE Asset RENAME TO AssetOud`);
|
|
run(`CREATE TABLE Asset (
|
|
id INTEGER PRIMARY KEY,
|
|
symbol TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
unit TEXT NOT NULL,
|
|
subunit TEXT,
|
|
displayUnit TEXT NOT NULL CHECK (displayUnit IN ('unit','subunit')),
|
|
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= 9),
|
|
kind TEXT NOT NULL CHECK (kind IN ('crypto','metal','fiat')),
|
|
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')),
|
|
taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')),
|
|
balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)),
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
UNIQUE (symbol),
|
|
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
|
|
) STRICT;`);
|
|
const kolommen = 'id, symbol, name, searchName, description, unit, subunit, displayUnit, decimals, kind';
|
|
run(`INSERT INTO Asset (${kolommen}, taxCategory, taxScope, balanceCapture, createdAt, updatedAt)
|
|
SELECT ${kolommen}, CASE WHEN taxCategory = 'buiten' THEN NULL ELSE taxCategory END,
|
|
CASE taxCategory WHEN 'buiten' THEN 'none' END, balanceCapture, createdAt, updatedAt FROM AssetOud`);
|
|
run(`DROP TABLE AssetOud`);
|
|
run(`CREATE INDEX IxAssetSearchName ON Asset(searchName)`);
|
|
}
|
|
if (account) {
|
|
run(`ALTER TABLE Account RENAME TO AccountOud`);
|
|
run(`CREATE TABLE Account (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
|
|
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)),
|
|
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
|
|
closedAt TEXT,
|
|
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')),
|
|
taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')),
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
|
|
) STRICT;`);
|
|
const kolommen = 'id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt';
|
|
run(`INSERT INTO Account (${kolommen}, taxCategory, taxScope, createdAt, updatedAt)
|
|
SELECT ${kolommen}, CASE WHEN taxCategory = 'buiten' THEN NULL ELSE taxCategory END,
|
|
CASE taxCategory WHEN 'buiten' THEN 'none' END, createdAt, updatedAt FROM AccountOud`);
|
|
run(`DROP TABLE AccountOud`);
|
|
run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`);
|
|
}
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AssetOud%' OR sql LIKE '%AccountOud%'`)
|
|
.filter(r => /\b(AssetOud|AccountOud)\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 112 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
113: () => {
|
|
if (!tableExists('InterestSchedule') || columnExists('InterestSchedule', 'compounding')
|
|
|| !columnExists('InterestSchedule', 'updatedAt')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE InterestSchedule RENAME TO InterestScheduleOud`);
|
|
run(`CREATE TABLE InterestSchedule (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
principalAssetId INTEGER REFERENCES Asset(id) ON DELETE NO ACTION,
|
|
payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
note TEXT,
|
|
principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0),
|
|
annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0),
|
|
rateKind TEXT NOT NULL CHECK (rateKind IN ('fixed','variable')),
|
|
creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN ('term_end','annual','quarterly','monthly','weekly')),
|
|
creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6),
|
|
creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31),
|
|
startsOn TEXT NOT NULL,
|
|
endsOn TEXT,
|
|
compounding INTEGER NOT NULL CHECK (compounding IN (0,1)),
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT,
|
|
CHECK (rateKind = 'fixed' OR (principalAmount IS NULL AND annualRate IS NULL)),
|
|
CHECK (creditWeekday IS NULL OR creditCadence = 'weekly'),
|
|
CHECK (creditDayOfMonth IS NULL OR creditCadence = 'monthly'),
|
|
CHECK (endsOn IS NULL OR endsOn >= startsOn),
|
|
CHECK (compounding = 0 OR (rateKind = 'fixed' AND principalAmount IS NOT NULL AND principalAssetId = payoutAssetId))
|
|
) STRICT;`);
|
|
const kolommen = `id, accountId, principalAssetId, payoutAssetId, note, principalAmount, annualRate, rateKind,
|
|
creditCadence, creditWeekday, creditDayOfMonth, startsOn, endsOn`;
|
|
run(`INSERT INTO InterestSchedule (${kolommen}, compounding, createdAt, updatedAt)
|
|
SELECT ${kolommen}, 0, createdAt, updatedAt FROM InterestScheduleOud`);
|
|
run(`DROP TABLE InterestScheduleOud`);
|
|
run(`CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId)`);
|
|
run(`CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId)`);
|
|
run(`CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%InterestScheduleOud%'`)
|
|
.filter(r => /\bInterestScheduleOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 113 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
114: () => {
|
|
if (!tableExists('Account') || columnExists('Account', 'wealthScope')
|
|
|| !columnExists('Account', 'taxScope')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Account RENAME TO AccountOud`);
|
|
run(`CREATE TABLE Account (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
searchName TEXT NOT NULL,
|
|
description TEXT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
|
|
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)),
|
|
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
|
|
closedAt TEXT,
|
|
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN ('bank','overig')),
|
|
taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN ('all','category','none')),
|
|
wealthScope TEXT CHECK (wealthScope IS NULL OR wealthScope IN ('managed','none')),
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
|
|
) STRICT;`);
|
|
const kolommen = 'id, name, searchName, description, kind, isOwnCustody, scanEnabled, isActive, closedAt, taxCategory, taxScope';
|
|
run(`INSERT INTO Account (${kolommen}, wealthScope, createdAt, updatedAt)
|
|
SELECT ${kolommen}, NULL, createdAt, updatedAt FROM AccountOud`);
|
|
run(`DROP TABLE AccountOud`);
|
|
run(`CREATE INDEX IxAccountSearchName ON Account(searchName)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AccountOud%'`)
|
|
.filter(r => /\bAccountOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 114 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
115: () => serviceProviderRow(),
|
|
|
|
116: () => serviceProviderRow(),
|
|
|
|
117: () => serviceProviderRow(),
|
|
|
|
118: () => {
|
|
if (tableExists('IntegrationCall')) return;
|
|
run(`CREATE TABLE IntegrationCall (
|
|
id INTEGER PRIMARY KEY,
|
|
target TEXT NOT NULL CHECK (target IN ('service','provider')),
|
|
providerKey TEXT,
|
|
operation TEXT NOT NULL,
|
|
subject TEXT,
|
|
outcome TEXT NOT NULL CHECK (outcome IN ('fail','empty','refused')),
|
|
httpStatus INTEGER CHECK (httpStatus IS NULL OR httpStatus BETWEEN 100 AND 599),
|
|
errorKey TEXT,
|
|
detail TEXT CHECK (detail IS NULL OR length(detail) <= 300),
|
|
latencyMs INTEGER CHECK (latencyMs IS NULL OR latencyMs >= 0),
|
|
requestId TEXT,
|
|
occurredAt TEXT NOT NULL,
|
|
CHECK ((target = 'service') = (providerKey IS NULL)),
|
|
CHECK (requestId IS NULL OR target = 'service')
|
|
) STRICT`);
|
|
run(`CREATE INDEX IxIntegrationCallOccurredAt ON IntegrationCall(occurredAt)`);
|
|
},
|
|
|
|
119: () => {
|
|
if (!tableExists('WalletTransaction') || columnExists('WalletTransaction', 'layer')
|
|
|| !columnExists('WalletTransaction', 'blockTime')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE WalletTransaction RENAME TO WalletTransactionOud`);
|
|
run(`CREATE TABLE WalletTransaction (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE,
|
|
labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL,
|
|
layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')),
|
|
txid TEXT NOT NULL,
|
|
address TEXT,
|
|
valueSats INTEGER NOT NULL,
|
|
feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0),
|
|
blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0),
|
|
blockTime TEXT,
|
|
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
|
|
createdAt TEXT NOT NULL,
|
|
CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL))
|
|
OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL))
|
|
) STRICT;`);
|
|
const kolommen = 'id, accountId, sourceId, labelId, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt';
|
|
run(`INSERT INTO WalletTransaction (${kolommen}, layer)
|
|
SELECT ${kolommen}, 'onchain' FROM WalletTransactionOud`);
|
|
run(`DROP TABLE WalletTransactionOud`);
|
|
run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`);
|
|
run(`CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'`);
|
|
run(`CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)`);
|
|
run(`CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)`);
|
|
run(`CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletTransactionOud%'`)
|
|
.filter(r => /\bWalletTransactionOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 119 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
120: () => {
|
|
if (!tableExists('WalletSource') || columnExists('WalletSource', 'balanceSats')
|
|
|| !columnExists('WalletSource', 'scriptType')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE WalletSource RENAME TO WalletSourceOud`);
|
|
run(`CREATE TABLE WalletSource (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT,
|
|
value TEXT NOT NULL,
|
|
name TEXT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('xpub','address','nwc','lnd')),
|
|
scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN ('p2tr','p2wpkh','p2sh-p2wpkh','p2pkh')),
|
|
balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0),
|
|
lastScannedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT,
|
|
CHECK (scriptType IS NULL OR kind = 'xpub'),
|
|
CHECK ((kind = 'lnd') = (connectionId IS NOT NULL)),
|
|
CHECK ((balanceSats IS NULL) = (lastScannedAt IS NULL)),
|
|
CHECK (balanceSats IS NULL OR kind IN ('lnd','nwc'))
|
|
) STRICT;`);
|
|
const kolommen = 'id, accountId, connectionId, value, name, kind, scriptType';
|
|
run(`INSERT INTO WalletSource (${kolommen}, balanceSats, lastScannedAt, createdAt, updatedAt)
|
|
SELECT ${kolommen}, NULL, NULL, createdAt, updatedAt FROM WalletSourceOud`);
|
|
run(`DROP TABLE WalletSourceOud`);
|
|
run(`CREATE INDEX IxWalletSourceAccountId ON WalletSource(accountId)`);
|
|
run(`CREATE INDEX IxWalletSourceConnectionId ON WalletSource(connectionId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletSourceOud%'`)
|
|
.filter(r => /\bWalletSourceOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 120 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
121: () => {
|
|
if (!tableExists('WalletTransaction') || columnExists('WalletTransaction', 'kind')
|
|
|| !columnExists('WalletTransaction', 'layer')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE WalletTransaction RENAME TO WalletTransactionOud`);
|
|
run(`CREATE TABLE WalletTransaction (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE,
|
|
labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL,
|
|
layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')),
|
|
kind TEXT CHECK (kind IS NULL OR kind IN ('channel','self')),
|
|
txid TEXT NOT NULL,
|
|
address TEXT,
|
|
valueSats INTEGER NOT NULL,
|
|
feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0),
|
|
blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0),
|
|
blockTime TEXT,
|
|
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
|
|
createdAt TEXT NOT NULL,
|
|
CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL))
|
|
OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)),
|
|
CHECK (kind IS NULL OR (kind = 'channel') = (layer = 'onchain'))
|
|
) STRICT;`);
|
|
const kolommen = 'id, accountId, sourceId, labelId, layer, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt';
|
|
run(`INSERT INTO WalletTransaction (${kolommen}, kind)
|
|
SELECT ${kolommen}, NULL FROM WalletTransactionOud`);
|
|
run(`DROP TABLE WalletTransactionOud`);
|
|
run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`);
|
|
run(`CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'`);
|
|
run(`CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)`);
|
|
run(`CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)`);
|
|
run(`CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletTransactionOud%'`)
|
|
.filter(r => /\bWalletTransactionOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 121 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
122: () => {
|
|
if (!tableExists('ReconcileProposal')) return;
|
|
const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ReconcileProposal'`)?.sql || '';
|
|
if (ddl.includes('accepted_interest') || !ddl.includes('auto_linked')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE ReconcileProposal RENAME TO ReconcileProposalOud`);
|
|
run(`CREATE TABLE ReconcileProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
walletTransactionId INTEGER REFERENCES WalletTransaction(id) ON DELETE CASCADE,
|
|
resolvedEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL,
|
|
transactionHash TEXT,
|
|
amount INTEGER NOT NULL CHECK (amount > 0),
|
|
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
|
|
suggestedType TEXT NOT NULL CHECK (suggestedType IN ('trade','transfer')),
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted_trade','accepted_transfer','accepted_interest','ignored','auto_linked')),
|
|
detectedAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
const kolommen = 'id, accountId, assetId, walletTransactionId, resolvedEventId, transactionHash, amount, direction, suggestedType, status, detectedAt';
|
|
run(`INSERT INTO ReconcileProposal (${kolommen}) SELECT ${kolommen} FROM ReconcileProposalOud`);
|
|
run(`DROP TABLE ReconcileProposalOud`);
|
|
run(`CREATE INDEX IxReconcileProposalAccountId ON ReconcileProposal(accountId)`);
|
|
run(`CREATE INDEX IxReconcileProposalAssetId ON ReconcileProposal(assetId)`);
|
|
run(`CREATE INDEX IxReconcileProposalWalletTransactionId ON ReconcileProposal(walletTransactionId)`);
|
|
run(`CREATE INDEX IxReconcileProposalResolvedEventId ON ReconcileProposal(resolvedEventId)`);
|
|
run(`CREATE INDEX IxReconcileProposalStatus ON ReconcileProposal(status)`);
|
|
run(`CREATE UNIQUE INDEX UxReconcileProposalLink ON ReconcileProposal(walletTransactionId, resolvedEventId) WHERE walletTransactionId IS NOT NULL`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%ReconcileProposalOud%'`)
|
|
.filter(r => /\bReconcileProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 122 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
123: () => {
|
|
if (!tableExists('WalletTransaction') || columnExists('WalletTransaction', 'memo')
|
|
|| !columnExists('WalletTransaction', 'kind')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE WalletTransaction RENAME TO WalletTransactionOud`);
|
|
run(`CREATE TABLE WalletTransaction (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE,
|
|
labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL,
|
|
layer TEXT NOT NULL CHECK (layer IN ('onchain','lightning')),
|
|
kind TEXT CHECK (kind IS NULL OR kind IN ('channel','self')),
|
|
txid TEXT NOT NULL,
|
|
address TEXT,
|
|
memo TEXT CHECK (memo IS NULL OR length(memo) <= 2000),
|
|
valueSats INTEGER NOT NULL,
|
|
feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0),
|
|
blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0),
|
|
blockTime TEXT,
|
|
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
|
|
createdAt TEXT NOT NULL,
|
|
CHECK ((layer = 'onchain' AND (blockHeight IS NULL) = (blockTime IS NULL))
|
|
OR (layer = 'lightning' AND blockHeight IS NULL AND blockTime IS NOT NULL AND sourceId IS NOT NULL)),
|
|
CHECK (kind IS NULL OR (kind = 'channel') = (layer = 'onchain')),
|
|
CHECK (memo IS NULL OR layer = 'lightning')
|
|
) STRICT;`);
|
|
const kolommen = 'id, accountId, sourceId, labelId, layer, kind, txid, address, valueSats, feeSats, blockHeight, blockTime, direction, createdAt';
|
|
run(`INSERT INTO WalletTransaction (${kolommen}, memo)
|
|
SELECT ${kolommen}, NULL FROM WalletTransactionOud`);
|
|
run(`DROP TABLE WalletTransactionOud`);
|
|
run(`CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'`);
|
|
run(`CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'`);
|
|
run(`CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId)`);
|
|
run(`CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId)`);
|
|
run(`CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletTransactionOud%'`)
|
|
.filter(r => /\bWalletTransactionOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 123 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
124: () => {
|
|
if (!tableExists('WalletSource') || !columnExists('WalletSource', 'balanceSats')) return;
|
|
const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'WalletSource'`)?.sql || '';
|
|
if (!ddl.includes(`(kind = 'lnd') = (connectionId IS NOT NULL)`)) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE WalletSource RENAME TO WalletSourceOud`);
|
|
run(`CREATE TABLE WalletSource (
|
|
id INTEGER PRIMARY KEY,
|
|
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
|
|
connectionId INTEGER REFERENCES Connection(id) ON DELETE RESTRICT,
|
|
value TEXT NOT NULL,
|
|
name TEXT,
|
|
kind TEXT NOT NULL CHECK (kind IN ('xpub','address','nwc','lnd')),
|
|
scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN ('p2tr','p2wpkh','p2sh-p2wpkh','p2pkh')),
|
|
balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0),
|
|
lastScannedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT,
|
|
CHECK (scriptType IS NULL OR kind = 'xpub'),
|
|
CHECK (kind <> 'lnd' OR connectionId IS NOT NULL),
|
|
CHECK (connectionId IS NULL OR kind IN ('lnd','xpub','address')),
|
|
CHECK ((balanceSats IS NULL) = (lastScannedAt IS NULL)),
|
|
CHECK (balanceSats IS NULL OR kind IN ('lnd','nwc'))
|
|
) STRICT;`);
|
|
const kolommen = 'id, accountId, connectionId, value, name, kind, scriptType, balanceSats, lastScannedAt, createdAt, updatedAt';
|
|
run(`INSERT INTO WalletSource (${kolommen}) SELECT ${kolommen} FROM WalletSourceOud`);
|
|
run(`DROP TABLE WalletSourceOud`);
|
|
run(`CREATE INDEX IxWalletSourceAccountId ON WalletSource(accountId)`);
|
|
run(`CREATE INDEX IxWalletSourceConnectionId ON WalletSource(connectionId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%WalletSourceOud%'`)
|
|
.filter(r => /\bWalletSourceOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 124 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
125: () => {
|
|
if (!tableExists('AiProposal') || columnExists('AiProposal', 'unitFee')
|
|
|| !columnExists('AiProposal', 'currency')) return;
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE AiProposal RENAME TO AiProposalOud`);
|
|
run(`CREATE TABLE AiProposal (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE,
|
|
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE,
|
|
createdEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL,
|
|
createdAccountId INTEGER REFERENCES Account(id) ON DELETE SET NULL,
|
|
createdValuationId INTEGER REFERENCES Valuation(id) ON DELETE SET NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('price_point','account','event','account_delete','valuation','portfolio','preference','source_choice')),
|
|
name TEXT,
|
|
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN ('exchange','wallet','lightning','card','bank','vault')),
|
|
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
|
|
eventType TEXT CHECK (eventType IS NULL OR eventType IN ('buy','sell','transfer','interest')),
|
|
amount INTEGER CHECK (amount IS NULL OR amount > 0),
|
|
fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice > 0),
|
|
fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0),
|
|
unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0),
|
|
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
|
|
observedAt TEXT,
|
|
scope TEXT,
|
|
key TEXT,
|
|
value TEXT,
|
|
currency TEXT CHECK (currency IS NULL OR currency GLOB '[A-Z][A-Z][A-Z]'),
|
|
note TEXT,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','rejected')),
|
|
detail TEXT,
|
|
resolvedAt TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
CHECK (kind <> 'price_point' OR (assetId IS NOT NULL AND fiatPrice IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind <> 'account' OR (name IS NOT NULL AND accountKind IS NOT NULL AND isOwnCustody IS NOT NULL)),
|
|
CHECK (kind <> 'event' OR (assetId IS NOT NULL AND eventType IS NOT NULL AND amount IS NOT NULL
|
|
AND observedAt IS NOT NULL
|
|
AND (fromAccountId IS NOT NULL OR toAccountId IS NOT NULL))),
|
|
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR fiatPrice IS NOT NULL),
|
|
CHECK (unitFee IS NULL OR (kind = 'event' AND eventType IN ('sell','transfer'))),
|
|
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
|
|
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
|
|
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
|
|
CHECK (kind = 'valuation' OR balance IS NULL),
|
|
CHECK (kind <> 'portfolio' OR (name IS NOT NULL AND currency IS NOT NULL)),
|
|
CHECK (kind = 'portfolio' OR currency IS NULL),
|
|
CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)),
|
|
CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN ('spot','history')
|
|
AND key IS NOT NULL AND value IN ('first','remove'))),
|
|
CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL))
|
|
) STRICT;`);
|
|
const kolommen = `id, assetId, accountId, fromAccountId, toAccountId, createdEventId, createdAccountId,
|
|
createdValuationId, kind, name, accountKind, isOwnCustody, eventType, amount, fiatPrice, fiatFee, balance,
|
|
observedAt, scope, key, value, currency, note, status, detail, resolvedAt, createdAt`;
|
|
run(`INSERT INTO AiProposal (${kolommen}) SELECT ${kolommen} FROM AiProposalOud`);
|
|
run(`DROP TABLE AiProposalOud`);
|
|
run(`CREATE INDEX IxAiProposalStatus ON AiProposal(status)`);
|
|
run(`CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId)`);
|
|
run(`CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId)`);
|
|
run(`CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId)`);
|
|
run(`CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%AiProposalOud%'`)
|
|
.filter(r => /\bAiProposalOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 125 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
},
|
|
|
|
126: () => {
|
|
if (tableExists('Event')) {
|
|
const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'Event'`)?.sql || '';
|
|
if (!ddl.includes(`'reservation'`) && columnExists('Event', 'priceOrigin') && columnExists('Event', 'fiatPrice')) rebuildEvent126();
|
|
}
|
|
if (tableExists('Notification')) {
|
|
const ddl = one(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'Notification'`)?.sql || '';
|
|
if (!ddl.includes(`'reservation'`)) {
|
|
run(`ALTER TABLE Notification RENAME TO NotificationOud`);
|
|
run(`CREATE TABLE Notification (
|
|
id INTEGER PRIMARY KEY,
|
|
count INTEGER NOT NULL CHECK (count >= 0),
|
|
highWater INTEGER NOT NULL CHECK (highWater >= 0),
|
|
kind TEXT NOT NULL CHECK (kind IN ('reconcile','interest','valuation','schedule_ended','source_proposal','ai_proposal','reservation')),
|
|
dismissedAt TEXT,
|
|
createdAt TEXT NOT NULL
|
|
) STRICT;`);
|
|
run(`INSERT INTO Notification (id, count, highWater, kind, dismissedAt, createdAt)
|
|
SELECT id, count, highWater, kind, dismissedAt, createdAt FROM NotificationOud`);
|
|
run(`DROP TABLE NotificationOud`);
|
|
run(`CREATE INDEX IxNotificationKind ON Notification(kind)`);
|
|
}
|
|
}
|
|
},
|
|
|
|
127: () => {
|
|
if (tableExists('Preference')) {
|
|
run(`INSERT INTO Preference (scope, key, value) VALUES ('app', 'locale', '"nl"')
|
|
ON CONFLICT (scope, key) DO NOTHING`);
|
|
}
|
|
},
|
|
|
|
128: () => {
|
|
if (!tableExists('PriceHistory') || columnExists('PriceHistory', 'sourceName')) return;
|
|
const vorm = ['assetId', 'providerId', 'fiatPrice', 'resolution', 'observedAt'].every(k => columnExists('PriceHistory', k));
|
|
if (vorm) rebuildPriceHistory128();
|
|
else run(`ALTER TABLE PriceHistory ADD COLUMN sourceName TEXT`);
|
|
},
|
|
};
|
|
|
|
function rebuildPriceHistory128() {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE PriceHistory RENAME TO PriceHistoryOud`);
|
|
run(`CREATE TABLE PriceHistory (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE RESTRICT,
|
|
fiatPrice REAL NOT NULL,
|
|
resolution TEXT NOT NULL CHECK (resolution IN ('day','timestamp')),
|
|
observedAt TEXT NOT NULL,
|
|
sourceName TEXT,
|
|
createdAt TEXT,
|
|
updatedAt TEXT,
|
|
UNIQUE (assetId, providerId, observedAt, resolution)
|
|
) STRICT`);
|
|
const oud = new Set(all(`PRAGMA table_info(PriceHistoryOud)`).map(c => c.name));
|
|
const kolommen = ['id', 'assetId', 'providerId', 'fiatPrice', 'resolution', 'observedAt', 'createdAt', 'updatedAt']
|
|
.filter(k => oud.has(k)).join(', ');
|
|
run(`INSERT INTO PriceHistory (${kolommen}) SELECT ${kolommen} FROM PriceHistoryOud`);
|
|
run(`DROP TABLE PriceHistoryOud`);
|
|
run(`CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId)`);
|
|
run(`CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%PriceHistoryOud%'`)
|
|
.filter(r => /\bPriceHistoryOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 128 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
}
|
|
|
|
function rebuildEvent126() {
|
|
run(`PRAGMA foreign_keys = OFF`);
|
|
run(`PRAGMA legacy_alter_table = ON`);
|
|
try {
|
|
run(`BEGIN`);
|
|
run(`ALTER TABLE Event RENAME TO EventOud`);
|
|
run(`CREATE TABLE Event (
|
|
id INTEGER PRIMARY KEY,
|
|
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
|
|
fromAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION,
|
|
productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION,
|
|
productCount INTEGER CHECK (productCount IS NULL OR productCount > 0),
|
|
importReference TEXT,
|
|
externalAddress TEXT,
|
|
note TEXT,
|
|
amount INTEGER NOT NULL CHECK (amount > 0),
|
|
fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice >= 0),
|
|
fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0),
|
|
unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0),
|
|
priceOrigin TEXT CHECK (priceOrigin IS NULL OR priceOrigin IN ('paid','market_day','market_moment','none')),
|
|
type TEXT NOT NULL CHECK (type IN ('buy','sell','transfer','interest','reservation')),
|
|
source TEXT NOT NULL CHECK (source IN ('manual','import','reconcile','lightning')),
|
|
occurredAt TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT,
|
|
CHECK ((productId IS NULL) = (productCount IS NULL)),
|
|
CHECK (type NOT IN ('buy','sell') OR fiatPrice IS NOT NULL),
|
|
CHECK (type <> 'reservation' OR ((fromAccountId IS NULL) <> (toAccountId IS NULL)
|
|
AND fiatPrice IS NULL AND fiatFee IS NULL AND unitFee IS NULL
|
|
AND productId IS NULL AND priceOrigin IS NULL
|
|
AND importReference IS NULL AND externalAddress IS NULL))
|
|
) STRICT`);
|
|
const kolommen = `id, assetId, fromAccountId, toAccountId, productId, productCount, importReference,
|
|
externalAddress, note, amount, fiatPrice, fiatFee, unitFee, priceOrigin, type, source, occurredAt,
|
|
createdAt, updatedAt`;
|
|
run(`INSERT INTO Event (${kolommen}) SELECT ${kolommen} FROM EventOud`);
|
|
run(`DROP TABLE EventOud`);
|
|
run(`CREATE INDEX IxEventAssetId ON Event(assetId)`);
|
|
run(`CREATE INDEX IxEventFromAccountId ON Event(fromAccountId)`);
|
|
run(`CREATE INDEX IxEventToAccountId ON Event(toAccountId)`);
|
|
run(`CREATE INDEX IxEventProductId ON Event(productId)`);
|
|
run(`CREATE INDEX IxEventOccurredAt ON Event(occurredAt)`);
|
|
run(`CREATE INDEX IxEventType ON Event(type)`);
|
|
run(`CREATE INDEX IxEventExternalAddress ON Event(externalAddress)`);
|
|
run(`CREATE INDEX IxEventImportReference ON Event(importReference)`);
|
|
const besmet = all(`SELECT name, sql FROM sqlite_master WHERE sql LIKE '%EventOud%'`)
|
|
.filter(r => /\bEventOud\b/.test(String(r.sql).replace(/--[^\n]*/g, '')));
|
|
if (besmet.length) {
|
|
throw new Error(
|
|
`Migration 126 aborted: ${besmet.map(r => r.name).join(', ')} still refers to a temporary ` +
|
|
`table name. The step was rolled back and the database is unchanged.`);
|
|
}
|
|
run(`COMMIT`);
|
|
} catch (e) {
|
|
try { run(`ROLLBACK`); } catch { }
|
|
throw e;
|
|
} finally {
|
|
run(`PRAGMA legacy_alter_table = OFF`);
|
|
run(`PRAGMA foreign_keys = ON`);
|
|
}
|
|
}
|
|
|
|
function serviceProviderRow() {
|
|
const kolommen = ['key', 'name', 'searchName', 'baseUrl', 'authenticationKind', 'isSystem', 'createdAt'];
|
|
const aanwezig = new Set(tableExists('Provider')
|
|
? all(`SELECT name FROM pragma_table_info('Provider')`).map(r => r.name) : []);
|
|
if (!kolommen.every(k => aanwezig.has(k))) return;
|
|
if (one(`SELECT 1 AS x FROM Provider WHERE key = 'atreides_services'`)) return;
|
|
run(`INSERT INTO Provider
|
|
(key, name, searchName, baseUrl, authenticationHeaderName, authenticationParameterName,
|
|
authenticationKind, authenticationRequired, isActive, isSeed, isSystem, createdAt)
|
|
VALUES ('atreides_services', 'Atreides Services', 'atreides services', '', NULL, NULL, 'none', 0, 1, 1, 1, ?)`,
|
|
[new Date().toISOString()]);
|
|
}
|
|
|
|
function tableExists(name) {
|
|
return !!one(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?`, [name]);
|
|
}
|
|
|
|
function columnExists(table, column) {
|
|
const r = one(`SELECT sql AS s FROM sqlite_master WHERE type = 'table' AND name = ?`, [table]);
|
|
return !!r && new RegExp(`[(,]\\s*["\`\\[]?${column}["\`\\]]?\\s`, 'i').test(String(r.s));
|
|
}
|
|
|
|
export function readVersion() {
|
|
if (!tableExists('Schema')) {
|
|
throw new Error(
|
|
'This database is from before the new layout and cannot be opened with this version of the ' +
|
|
'app. The conversion script was removed on 14-08-2026; it is still in the git history ' +
|
|
'(git log --all -- scripts/baseline-convert.mjs), as is a checkout from before the baseline ' +
|
|
'commit.');
|
|
}
|
|
const r = one(`SELECT version AS v FROM Schema`);
|
|
if (!r) throw new Error('Cannot determine the schema version. The database is inconsistent.');
|
|
return r.v;
|
|
}
|
|
|
|
function writeVersion(v) {
|
|
run(`UPDATE Schema SET version = ?`, [v]);
|
|
}
|
|
|
|
export function runMigrations() {
|
|
let found = readVersion();
|
|
if (found > CONFIG.SCHEMA_VERSION) {
|
|
throw new Error(`DB version ${found} is newer than the app (${CONFIG.SCHEMA_VERSION}).`);
|
|
}
|
|
if (found < BASELINE_VERSION) {
|
|
throw new Error(
|
|
`This database is at version ${found} and thus from before the new layout ` +
|
|
`(version ${BASELINE_VERSION}). The migrations that would update it step by step no longer ` +
|
|
`exist, and neither does the conversion script: see git log --all -- scripts/baseline-convert.mjs.`);
|
|
}
|
|
let migrated = false;
|
|
while (found < CONFIG.SCHEMA_VERSION) {
|
|
const next = found + 1;
|
|
const m = MIGRATIONS[next];
|
|
if (!m) throw new Error(`The migration to version ${next} is missing.`);
|
|
m();
|
|
writeVersion(next);
|
|
found = next;
|
|
migrated = true;
|
|
}
|
|
return migrated;
|
|
}
|