Files
AtreidesAssetsFree/server/db/schema.js
T

1239 lines
94 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:da89e340dd3822fdda071264a61e9eb52014fccd9317846885a90d5b773c4e19
import { AUTH_KINDS, CONFIG, ENUMS, CAPABILITIES, SOURCE_CAPABILITIES, OUTPUT_FIELDS, TRANSFORMS, HTTP_METHODS } from '../../shared/config.js';
const lijst = (waarden) => waarden.map(w => `'${w}'`).join(',');
const ASSET_KIND = lijst(ENUMS.ASSET_KIND);
const ASSET_DISPLAY_UNIT = lijst(ENUMS.ASSET_DISPLAY_UNIT);
const ACCOUNT_KIND = lijst(ENUMS.ACCOUNT_KIND);
const TAX_CATEGORY = lijst(ENUMS.TAX_CATEGORY);
const TAX_SCOPE = lijst(ENUMS.TAX_SCOPE);
const WEALTH_SCOPE = lijst(ENUMS.WEALTH_SCOPE);
const METAL_FORM = lijst(ENUMS.METAL_FORM);
const EVENT_TYPE = lijst(ENUMS.EVENT_TYPE);
const LEDGER_EVENT_TYPE = lijst(ENUMS.LEDGER_EVENT_TYPE);
const EVENT_SOURCE = lijst(ENUMS.EVENT_SOURCE);
const PRICE_ORIGIN = lijst(ENUMS.EVENT_PRICE_ORIGIN);
const VALUATION_SOURCE = lijst(ENUMS.VALUATION_SOURCE);
const SCAN_STATUS = lijst(ENUMS.SCAN_STATUS);
const DIRECTION = lijst(ENUMS.RECONCILE_DIRECTION);
const WALLET_TX_LAYER = lijst(ENUMS.WALLET_TX_LAYER);
const WALLET_TX_KIND = lijst(ENUMS.WALLET_TX_KIND);
const SUGGESTED_TYPE = lijst(ENUMS.RECONCILE_SUGGESTED);
const PROPOSAL_STATUS = lijst(ENUMS.RECONCILE_STATUS);
const LINK_METHOD = lijst(ENUMS.LINK_METHOD);
const WALLET_SOURCE_KIND = lijst(ENUMS.WALLET_SOURCE_KIND);
const SCRIPT_TYPE = lijst(ENUMS.SCRIPT_TYPE);
const RATE_KIND = lijst(ENUMS.INTEREST_RATE_KIND);
const CREDIT_CADENCE = lijst(ENUMS.INTEREST_CADENCE);
const CREDIT_STATUS = lijst(ENUMS.INTEREST_CREDIT_STATUS);
const NOTIFICATION_KIND = lijst(ENUMS.NOTIFICATION_KIND);
const CONNECTION_KIND = lijst(ENUMS.CONNECTION_KIND);
const CONNECTION_STATUS = lijst(ENUMS.CONNECTION_STATUS);
const AI_CALL_OUTCOME = lijst(ENUMS.AI_CALL_OUTCOME);
const AI_MESSAGE_KIND = lijst(ENUMS.AI_MESSAGE_KIND);
const INTEGRATION_TARGET = lijst(ENUMS.INTEGRATION_TARGET);
const INTEGRATION_OUTCOME = lijst(ENUMS.INTEGRATION_OUTCOME);
const COMMAND_KIND = lijst(ENUMS.COMMAND_KIND);
const COMMAND_STATE = lijst(ENUMS.COMMAND_STATE);
const AI_PROPOSAL_KIND = lijst(ENUMS.AI_PROPOSAL_KIND);
const AI_PROPOSAL_STATUS = lijst(ENUMS.AI_PROPOSAL_STATUS);
const PRICE_RESOLUTION = lijst(ENUMS.PRICE_RESOLUTION);
const CHANGE_OPERATION = lijst(ENUMS.CHANGE_OPERATION);
const CHANGE_ORIGIN = lijst(ENUMS.CHANGE_ORIGIN);
const SOURCE_STATUS = lijst(ENUMS.PRICE_SOURCE_STATUS);
const HASH_ALGORITHM = lijst(ENUMS.HMAC_HASH_ALGORITHM);
const HMAC_ENCODING = lijst(ENUMS.HMAC_ENCODING);
const AUTH_KIND = lijst(Object.keys(AUTH_KINDS));
const CAPABILITY = lijst(CAPABILITIES);
const SOURCE_CAPABILITY = lijst(SOURCE_CAPABILITIES);
const SOURCE_CHOICE_ACTION = lijst(ENUMS.SOURCE_CHOICE_ACTION);
const QUOTE_UNIT = lijst(ENUMS.QUOTE_UNIT);
const OUTPUT_FIELD = lijst(OUTPUT_FIELDS);
const TRANSFORM = lijst(TRANSFORMS);
const HTTP_METHOD = lijst(HTTP_METHODS);
export const SCHEMA = `
-- ═══ Core ══════════════════════════════════════════════════════════
-- Asset: what everything hangs on. searchName is the normalised name (shared/search-name.js); symbol
-- is the hard key and it is really unique now, which it was not in the old schema even though the seed
-- deduplicates on it.
--
-- THE UPPER BOUND ON decimals IS MEASURED and not a round-number choice. A quantity is stored as
-- value * 10^decimals in an INTEGER column, and node:sqlite THROWS when reading back above
-- Number.MAX_SAFE_INTEGER. At 18 decimals (ETH) that bound sits at 0.009 units: the asset can be
-- created and breaks as soon as you use it. At 9 decimals it sits at over nine million units, ample
-- for every asset this app knows. Consequence you must know: an ETH-like asset with 18 decimals is now
-- refused at creation. That is intended, because it was not usable anyway; what changes is that it is
-- now a readable refusal instead of a silent break.
CREATE TABLE Asset (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL, -- 'BTC', 'XAU'; uppercase, a code and not UI text
name TEXT NOT NULL, -- UI text and therefore Dutch ('Goud')
searchName TEXT NOT NULL, -- normalised name; no UNIQUE: two assets may carry the same name, symbol separates them
description TEXT, -- NULL = not filled in; the UI does not write this field yet
unit TEXT NOT NULL, -- main unit (BTC, ozt)
subunit TEXT, -- NULL = this asset has no subunit (ETH, LINK)
displayUnit TEXT NOT NULL CHECK (displayUnit IN (${ASSET_DISPLAY_UNIT})),
decimals INTEGER NOT NULL CHECK (decimals >= 0 AND decimals <= ${CONFIG.MAX_ASSET_DECIMALS}), -- storage precision: amount = value * 10^decimals; the upper bound is measured, see the head
kind TEXT NOT NULL CHECK (kind IN (${ASSET_KIND})), -- discriminator: decides whether MetalProduct/subunit has meaning
-- The wealth category of this asset. NULL = derive from \`kind\`. The account goes first: see
-- \`categorieVoor\` in client/tax/constants.js for the order and why it is that way.
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN (${TAX_CATEGORY})),
-- Where this asset counts on the tax page. NULL = in the table and the result. The account goes
-- first: see \`reikwijdteVoor\` in client/tax/constants.js.
taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN (${TAX_SCOPE})),
-- Whether the account panel offers "Saldo vastleggen" for this asset. NULL = derive from \`kind\` (on
-- for fiat only), see \`balanceCaptureOn\` in shared/config.js.
balanceCapture INTEGER CHECK (balanceCapture IS NULL OR balanceCapture IN (0,1)),
createdAt TEXT, -- NULL = row predates migration 85
updatedAt TEXT, -- NULL = never changed since it was created
UNIQUE (symbol), -- idempotency of seedAssets and of the import: one row per symbol
-- Displaying in the subunit is only possible when it exists. Stood nowhere and is exactly the kind of
-- silent inconsistency this baseline closes.
CHECK (displayUnit <> 'subunit' OR subunit IS NOT NULL)
) STRICT;
CREATE INDEX IxAssetSearchName ON Asset(searchName); -- query: searching by name in the asset picker
-- Account: the place where an asset is held. isActive/closedAt: an inactive account disappears from the
-- pick lists but counts fully in the positions. That distinction lives in the read layer, not here.
CREATE TABLE Account (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
searchName TEXT NOT NULL, -- no UNIQUE: the user may name two accounts alike (different kind/custody)
description TEXT, -- NULL = not filled in
kind TEXT NOT NULL CHECK (kind IN (${ACCOUNT_KIND})),
isOwnCustody INTEGER NOT NULL CHECK (isOwnCustody IN (0,1)),
scanEnabled INTEGER NOT NULL CHECK (scanEnabled IN (0,1)), -- 0 = manual, 1 = automated through WalletSource
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
closedAt TEXT, -- NULL = not closed; ISO UTC when isActive = 0
-- The wealth category of what sits on this account. NULL = derive from the asset.
taxCategory TEXT CHECK (taxCategory IS NULL OR taxCategory IN (${TAX_CATEGORY})),
-- Where it counts on the tax page. NULL = as the asset says. This is where "mine but outside box 3"
-- belongs: a pension account simply holds euros, so the asset cannot tell. The account knows.
taxScope TEXT CHECK (taxScope IS NULL OR taxScope IN (${TAX_SCOPE})),
-- Whose it is. NULL = own wealth; 'managed' = held for someone else; 'none' = neither.
wealthScope TEXT CHECK (wealthScope IS NULL OR wealthScope IN (${WEALTH_SCOPE})),
createdAt TEXT, -- NULL = row predates migration 85
updatedAt TEXT, -- NULL = never changed since it was created
-- The flag and the date cannot contradict each other. This CHECK may be immutable: it guards a
-- boolean relation that never extends.
CHECK ((isActive = 1 AND closedAt IS NULL) OR (isActive = 0 AND closedAt IS NOT NULL))
) STRICT;
CREATE INDEX IxAccountSearchName ON Account(searchName); -- query: searching by name in the account picker
-- Label: free tags on addresses and movements.
CREATE TABLE Label (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT, -- NULL = not filled in
createdAt TEXT, -- NULL = row predates migration 85
updatedAt TEXT, -- NULL = never changed since it was created
-- STRICTER than the old schema, where UNIQUE sat on \`name\`: 'Werk' and 'werk' were two labels there.
-- Uniqueness belongs on the normalised form, because that is what the combobox treats as "already
-- exists". Counted on the backup: zero collisions, so the conversion does not get stuck here.
UNIQUE (searchName)
) STRICT;
-- MetalProduct: coins and bars per metal asset. fineGrams = fine weight per piece in the gram base
-- (x10^7); the name says "grams" and the unit is not that.
CREATE TABLE MetalProduct (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, -- without an asset a product has no meaning
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT, -- NULL = not filled in
fineGrams INTEGER NOT NULL CHECK (fineGrams > 0),
form TEXT NOT NULL CHECK (form IN (${METAL_FORM})),
createdAt TEXT, -- NULL = row predates migration 85
updatedAt TEXT, -- NULL = never changed since it was created
-- Idempotency of seedMetalProducts: one product per (asset, name). Note the key MUST contain assetId:
-- on the backup four product names ('Baar 1 kg', 'Maple Leaf 1 oz', …) carry the same name for gold
-- AND silver. UNIQUE(searchName) alone would topple the conversion; measured, not assumed.
UNIQUE (assetId, searchName)
) STRICT;
-- ═══ Providers and their profile ═══════════════════════════════════
-- Provider: a price source. \`key\` is the stable key for code and seeds, \`id\` is what the relations
-- point at; that is a natural key next to a surrogate and not redundancy.
-- The column \`coverage\` is GONE: it was a comma list and is now ProviderCoverage.
CREATE TABLE Provider (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL, -- 'mempool'; stable, renaming is a migration
name TEXT NOT NULL, -- UI text ('Handmatig'), stays Dutch
searchName TEXT NOT NULL,
description TEXT, -- NULL = not filled in
baseUrl TEXT NOT NULL, -- empty for the system provider 'manual'
authenticationHeaderName TEXT, -- NULL = this authenticationKind uses no header
authenticationParameterName TEXT, -- NULL = this authenticationKind uses no query parameter
authenticationKind TEXT NOT NULL CHECK (authenticationKind IN (${AUTH_KIND})),
authenticationRequired INTEGER NOT NULL CHECK (authenticationRequired IN (0,1)),
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
isSeed INTEGER NOT NULL CHECK (isSeed IN (0,1)),
isSystem INTEGER NOT NULL CHECK (isSystem IN (0,1)), -- 1 = 'manual': not editable, not deletable
createdAt TEXT NOT NULL,
UNIQUE (key) -- idempotency of seedProviders: one row per key
) STRICT;
CREATE INDEX IxProviderSearchName ON Provider(searchName); -- query: searching the provider list in Integraties
-- A 1:1 extension of Provider for the HMAC recipe. providerId IS the key; one of the three tables that
-- legitimately have no id of their own.
-- messageParts stays a comma list: it is read as a recipe in its entirety and never searched, unlike the
-- old Provider.coverage.
CREATE TABLE ProviderAuthenticationHmac (
providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE,
messageParts TEXT NOT NULL, -- 'timestamp,method,full_path,body'
keyHeader TEXT NOT NULL,
signatureHeader TEXT NOT NULL,
timestampHeader TEXT NOT NULL,
windowHeader TEXT, -- NULL = this provider knows no window header
windowMs INTEGER CHECK (windowMs IS NULL OR windowMs > 0),-- NULL = no window; belongs with windowHeader
hashAlgorithm TEXT NOT NULL CHECK (hashAlgorithm IN (${HASH_ALGORITHM})), -- was hash_algo: an abbreviation without gain
encoding TEXT NOT NULL CHECK (encoding IN (${HMAC_ENCODING})),
createdAt TEXT -- NULL = row predates migration 85
) STRICT;
-- A 1:1 extension of Provider for a FIXED rate. Same shape and the same reason as the HMAC recipe above:
-- it is a recipe and not a thing of its own.
--
-- WHY THIS EXISTS. The euro is the unit of account of the app (PriceHistory.fiatPrice), so the rate of a euro
-- account is by definition 1.00. Without a rate there is no price row, and without a price row the
-- balances tab refuses to store a balance and the tax page leaves the amount out of the totals. So an
-- asset needs a source, and PriceHistory.providerId is NOT NULL: the source MUST be a provider. This
-- table makes "fixed rate" a kind of provider instead of a branch in every consumer.
--
-- ONLY \`fiatPrice\`, in the unit of account: the one currency whose rate is fixed by definition.
--
-- Coverage (which symbols) lives in ProviderCoverage and not here: one fixed rate per provider is enough
-- as long as a provider covers one symbol, and a second fixed rate is a second row in Provider. The same
-- trade-off as with the two Eulerpool rows (crypto and metal separately).
CREATE TABLE ProviderFixedRate (
providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE,
fiatPrice REAL NOT NULL CHECK (fiatPrice > 0), -- 1.0 for the system provider 'fixed'; zero or negative is no rate
createdAt TEXT -- NULL = row predates migration 85
) STRICT;
-- ProviderCallLimit: how many calls a month the plan of this provider allows.
-- A 1:1 row next to the provider, like ProviderFixedRate, so a provider without a monthly limit simply has
-- none; no NULL column on every provider. The COUNT is not here: ApiCallMetric already has it per minute.
-- A calendar month in UTC, which is how the app counts; a provider may reset on its billing date.
CREATE TABLE ProviderCallLimit (
providerId INTEGER PRIMARY KEY REFERENCES Provider(id) ON DELETE CASCADE,
callsPerMonth INTEGER NOT NULL CHECK (callsPerMonth > 0),
createdAt TEXT
) STRICT;
CREATE TABLE ProviderEndpoint (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
-- With {symbol}/{external_id}/{date}; the resolver fills them in. EMPTY for a fixed-rate provider: no
-- request goes out, and the row only says WHICH capability it can answer. The same idiom as the empty
-- Provider.baseUrl of 'manual'.
pathTemplate TEXT NOT NULL,
queryTemplate TEXT, -- NULL = no query string
capability TEXT NOT NULL CHECK (capability IN (${CAPABILITY})),
method TEXT NOT NULL CHECK (method IN (${HTTP_METHOD})),
-- The currency the ANSWER is in, as the symbol of a fiat asset ('USD'). NULL = the unit of account of
-- the portfolio. Not a CHECK list: a symbol, like ProviderCoverage.
quoteCurrency TEXT,
-- How often this endpoint may be asked, in seconds. NULL = the pace of the app. For a spot it is how
-- long an answer stays fresh; for the others the minimum spacing between two requests.
minIntervalSeconds INTEGER CHECK (minIntervalSeconds IS NULL OR minIntervalSeconds > 0),
createdAt TEXT, -- NULL = row predates migration 85
UNIQUE (providerId, capability) -- one endpoint per capability: underpins the upsert in the builder UI
) STRICT;
-- ProviderInputMap: our symbols to the provider's keys. Was called ProviderSymbolMap; the new name is
-- the UI's own ("Input-mapping" next to "Output-mapping" in the builder), and it makes the pair with
-- ProviderOutputMap visible.
CREATE TABLE ProviderInputMap (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
symbol TEXT, -- our symbol; NULL only on the catch-all row. No FK to Asset, see ProviderCoverage
externalId TEXT NOT NULL, -- what the provider calls it ('gold')
quoteUnit TEXT CHECK (quoteUnit IS NULL OR quoteUnit IN (${QUOTE_UNIT})), -- NULL = the storage base; 'ozt' = per troy ounce, converted to per gram (migration 99)
isCatchAll INTEGER NOT NULL CHECK (isCatchAll IN (0,1)), -- 1 = for every symbol without a row of its own (migration 102)
createdAt TEXT, -- NULL = row predates migration 85
UNIQUE (providerId, symbol), -- one translation per symbol
CHECK ((isCatchAll = 1) = (symbol IS NULL)) -- the catch-all has no symbol, and only the catch-all
) STRICT;
CREATE UNIQUE INDEX UxProviderInputMapCatchAll ON ProviderInputMap(providerId) WHERE isCatchAll = 1; -- one catch-all per provider
CREATE TABLE ProviderOutputMap (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
jsonPath TEXT NOT NULL, -- 'prices[].EUR'; may contain {external_id}
capability TEXT NOT NULL CHECK (capability IN (${CAPABILITY})),
internalField TEXT NOT NULL CHECK (internalField IN (${OUTPUT_FIELD})),
transform TEXT NOT NULL CHECK (transform IN (${TRANSFORM})),
createdAt TEXT -- NULL = row predates migration 85
) STRICT;
CREATE INDEX IxProviderOutputMapProviderIdCapability ON ProviderOutputMap(providerId, capability); -- query: the adapter fetches the mapping per (provider, capability)
-- ProviderCoverage: which symbols a provider delivers. Was Provider.coverage, a comma list searched in
-- SQL with a LIKE trick. Deliberately NO FK to Asset: a provider may claim coverage on a symbol that is
-- not an asset here, and with an FK that coverage would disappear on saving. Keying on symbol text
-- mirrors ProviderInputMap.
CREATE TABLE ProviderCoverage (
id INTEGER PRIMARY KEY,
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
createdAt TEXT, -- NULL = row predates migration 85
UNIQUE (providerId, symbol) -- idempotency of syncing the coverage
) STRICT;
CREATE INDEX IxProviderCoverageSymbol ON ProviderCoverage(symbol); -- query: which providers cover this symbol (matrix columns)
-- Measurement points per provider per minute. A composite PK, no surrogate id: the third of the three
-- exceptions. bucketMinute is a key at minute precision ('2026-06-25T08:17') and deliberately not a full
-- timestamp.
CREATE TABLE ApiCallMetric (
providerId INTEGER NOT NULL REFERENCES Provider(id) ON DELETE CASCADE,
capability TEXT NOT NULL CHECK (capability IN (${CAPABILITY})),
bucketMinute TEXT NOT NULL,
okCount INTEGER NOT NULL CHECK (okCount >= 0),
failCount INTEGER NOT NULL CHECK (failCount >= 0),
sumLatencyMs INTEGER NOT NULL CHECK (sumLatencyMs >= 0),
maxLatencyMs INTEGER NOT NULL CHECK (maxLatencyMs >= 0),
PRIMARY KEY (providerId, capability, bucketMinute) -- underpins the ON CONFLICT summing per minute
) STRICT;
-- IntegrationCall: one row per call to Atreides Services or an own integration that went wrong (plan
-- Integratielog). A successful call gets no row: it is counted in ApiCallMetric, and a spot refresh every
-- minute would otherwise write over 1400 rows a day per source.
--
-- \`providerKey\` IS TEXT AND NOT AN FK: a log has to outlive the deletion of what it is about. NULL for the
-- service, which is not a provider you configure.
--
-- NO URL, NO HEADER, NO ANSWER. \`operation\` is the path of the service or the capability, never the built
-- URL (a query_key source carries its key in it). \`detail\` is cleaned of every secret the writer knows
-- before it lands here (server/data/integration-log.js).
--
-- Cleared on every write: older than CONFIG.INTEGRATION_LOG.RETENTION_DAYS, and above MAX_ROWS the oldest.
CREATE TABLE IntegrationCall (
id INTEGER PRIMARY KEY,
target TEXT NOT NULL CHECK (target IN (${INTEGRATION_TARGET})),
providerKey TEXT, -- NULL = the service (see above)
operation TEXT NOT NULL, -- '/v1/series' for the service, the capability for a provider
subject TEXT, -- NULL = not about one instrument; for /v1/spot the NUMBER of instruments, not the list
outcome TEXT NOT NULL CHECK (outcome IN (${INTEGRATION_OUTCOME})),
httpStatus INTEGER CHECK (httpStatus IS NULL OR httpStatus BETWEEN 100 AND 599), -- NULL = no answer came
errorKey TEXT, -- NULL = no key; the AppError key, plus the key of the service after a space
detail TEXT CHECK (detail IS NULL OR length(detail) <= ${CONFIG.INTEGRATION_LOG.DETAIL_MAX_CHARS}), -- NULL = nothing to add; cleaned
latencyMs INTEGER CHECK (latencyMs IS NULL OR latencyMs >= 0), -- NULL = not sent (refused)
requestId TEXT, -- NULL = not sent to the service; the X-Request-Id that went along
occurredAt TEXT NOT NULL, -- UTC to the millisecond, the moment of sending
CHECK ((target = 'service') = (providerKey IS NULL)),
CHECK (requestId IS NULL OR target = 'service')
) STRICT;
CREATE INDEX IxIntegrationCallOccurredAt ON IntegrationCall(occurredAt); -- query: the clearing by age and the window of recent failures
-- ═══ Connections and tracking sources ══════════════════════════════
-- Connection: endpoint profiles per kind. Only btc_explorer knows one app-wide active row; lnd/nwc may
-- have several active (those hang per account). Secrets (macaroon, NWC secret) are NOT here.
CREATE TABLE Connection (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT, -- NULL = not filled in
url TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (${CONNECTION_KIND})),
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
isSystem INTEGER NOT NULL CHECK (isSystem IN (0,1)), -- 1 = mempool.space: a guaranteed fallback, not deletable
lastStatus TEXT CHECK (lastStatus IS NULL OR lastStatus IN (${CONNECTION_STATUS})), -- NULL = not tested yet
lastCheckedAt TEXT, -- NULL = not tested yet; belongs with lastStatus
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
UNIQUE (kind, searchName), -- idempotency of seedConnections: one profile per (kind, name)
-- An outcome without a moment (or the other way round) says nothing. Both or neither.
CHECK ((lastStatus IS NULL) = (lastCheckedAt IS NULL))
) STRICT;
-- At most one active btc_explorer profile. Partial, so it does not cover the kind scan in general; nor
-- does it need to, because UNIQUE(kind, searchName) already has kind first.
CREATE UNIQUE INDEX UxConnectionActiveBtcExplorer ON Connection(kind) WHERE kind = 'btc_explorer' AND isActive = 1;
-- WalletSource: where an account gets its on-chain picture from. The NWC secret lives server-side in the
-- secrets file; \`value\` holds only the wallet pubkey for nwc.
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, -- the LND node: required on lnd, optional on xpub/address (its on-chain layer, migration 124). RESTRICT: emptying it would break the CHECK below
value TEXT NOT NULL, -- xpub, address or wallet pubkey
name TEXT, -- NULL = no name of its own; the UI then shows the source itself
kind TEXT NOT NULL CHECK (kind IN (${WALLET_SOURCE_KIND})),
scriptType TEXT CHECK (scriptType IS NULL OR scriptType IN (${SCRIPT_TYPE})), -- NULL = not applicable or not determined yet
-- The balance of a LIGHTNING source as the last update saw it (migration 120): for lnd
-- the channel outbound, for nwc the wallet's own balance. An on-chain source has none here, its balance
-- is the sum of its addresses (WalletAddress.balanceSats). The same names as on WalletAddress.
balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0), -- NULL = never updated, or an on-chain source
lastScannedAt TEXT, -- NULL = never updated; belongs with balanceSats
createdAt TEXT NOT NULL,
updatedAt TEXT, -- NULL = never changed since it was created
-- Conditional columns, until now only comments. Both counted on the backup: all five xpub sources have
-- a scriptType, the only lnd source has a connectionId, and no other kind carries one.
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;
CREATE INDEX IxWalletSourceAccountId ON WalletSource(accountId); -- FK scan on deleting an account + query: sources per account
CREATE INDEX IxWalletSourceConnectionId ON WalletSource(connectionId); -- FK scan on deleting a connection
-- WalletAddress. The column labelId was DROPPED at review: it stood in no INSERT, no UPDATE and no
-- SELECT, only in a comment at the upsert. A label on an address COULD therefore never exist. Should
-- that wish come after all, it is one migration. Labels on movements do stay: see
-- WalletTransaction.labelId.
CREATE TABLE WalletAddress (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
sourceId INTEGER REFERENCES WalletSource(id) ON DELETE CASCADE, -- NULL = a loose address without a source
address TEXT NOT NULL,
automaticLabel TEXT, -- NULL = not named automatically
chainIndex INTEGER CHECK (chainIndex IS NULL OR chainIndex IN (0,1)), -- BIP32 chain index: 0 = receive, 1 = change. NULL = loose address. Was called \`chain\`, which looked like it was about the blockchain
derivationIndex INTEGER CHECK (derivationIndex IS NULL OR derivationIndex >= 0), -- NULL = loose address; belongs with chainIndex
balanceSats INTEGER CHECK (balanceSats IS NULL OR balanceSats >= 0), -- snapshot of the last scan; NULL = never scanned
transactionCount INTEGER CHECK (transactionCount IS NULL OR transactionCount >= 0),-- idem
lastScannedAt TEXT, -- NULL = never scanned; belongs with balanceSats/transactionCount
createdAt TEXT, -- NULL = row predates migration 85
UNIQUE (address) -- underpins the idempotent address upsert per scan
) STRICT;
CREATE INDEX IxWalletAddressAccountId ON WalletAddress(accountId); -- FK scan on account delete + query: addresses per account
CREATE INDEX IxWalletAddressSourceId ON WalletAddress(sourceId); -- FK scan on source delete + query: addresses per source
-- WalletTransaction: a cache of the movements of a wallet, on-chain and on lightning (migration
-- 119). The two layers work the same everywhere else (the links to events, the proposals,
-- Tracking), and that is why they share a table.
--
-- ON-CHAIN: one row per (account, txid), the net delta for that account; \`txid\` is the transaction id.
-- LIGHTNING: one row per (source, txid), where \`txid\` is the payment hash (hex). Per SOURCE and not per
-- account, because an NWC wallet and a LND node on the same account see the same payment, and each keeps
-- its own row; the link between the two is a proposal.
--
-- \`blockTime\` IS THE MOMENT OF BOTH LAYERS, the time of the block or the time a payment settled. A new
-- column \`occurredAt\` would need a COALESCE in the 43 places that read \`blockTime\`, and one forgotten
-- reader would drop lightning without a sound. A lightning row has no height and always a time: only
-- settled payments come in, so it is never "unconfirmed".
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, -- NULL = an on-chain movement without a traceable source; a lightning row always has one
labelId INTEGER REFERENCES Label(id) ON DELETE SET NULL, -- NULL = no label
layer TEXT NOT NULL CHECK (layer IN (${WALLET_TX_LAYER})),
kind TEXT CHECK (kind IS NULL OR kind IN (${WALLET_TX_KIND})), -- NULL = an ordinary movement; 'channel' opened or closed a channel (on-chain), 'self' paid yourself (lightning). Migration 121
txid TEXT NOT NULL, -- the transaction id, or on lightning the payment hash. Stays txid: the term of every explorer and API
address TEXT, -- NULL = no address known; the match key with Event.externalAddress
memo TEXT CHECK (memo IS NULL OR length(memo) <= 2000), -- NULL = no memo; lightning only: the description of the invoice, for a zap the zap request (shared/zap.js). Migration 123
valueSats INTEGER NOT NULL,
feeSats INTEGER CHECK (feeSats IS NULL OR feeSats >= 0), -- NULL = fee unknown (an incoming movement)
blockHeight INTEGER CHECK (blockHeight IS NULL OR blockHeight > 0), -- NULL = still unconfirmed, or a lightning row
blockTime TEXT, -- NULL = still unconfirmed; the moment on both layers (see above)
direction TEXT NOT NULL CHECK (direction IN (${DIRECTION})),
createdAt TEXT NOT NULL,
-- On-chain: height and time come together out of the confirmation; one of the two is a half-processed
-- row. Lightning: no height, always a time, and always a source (the key below rests on it).
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)),
-- A channel lives on the chain, a payment to yourself on lightning.
CHECK (kind IS NULL OR (kind = 'channel') = (layer = 'onchain')),
CHECK (memo IS NULL OR layer = 'lightning')
) STRICT;
CREATE UNIQUE INDEX UxWalletTransactionAccountIdTxid ON WalletTransaction(accountId, txid) WHERE layer = 'onchain'; -- the idempotent scan upsert, on-chain: one row per account per transaction
CREATE UNIQUE INDEX UxWalletTransactionSourceIdTxid ON WalletTransaction(sourceId, txid) WHERE layer = 'lightning'; -- the same on lightning, per source (see above)
CREATE INDEX IxWalletTransactionAccountId ON WalletTransaction(accountId); -- FK scan on account delete: the unique indexes above are partial and cover only one layer each
CREATE INDEX IxWalletTransactionSourceId ON WalletTransaction(sourceId); -- FK scan on source delete + query: movements per source
CREATE INDEX IxWalletTransactionLabelId ON WalletTransaction(labelId); -- FK scan on label delete
-- ═══ The ledger ════════════════════════════════════════════════════
-- Event: buy, sell, transfer and interest in one table. Everything that computes holdings, tax or
-- reconcile sums here.
--
-- WHAT THE DATA SAID OTHER THAN THE PLAN. "fromAccountId and toAccountId belong to a transfer" was a
-- CHECK candidate. It is not true: on the backup EVERY buy has a toAccountId (where it arrives), EVERY
-- sell a fromAccountId, and interest a toAccountId. Such a CHECK would refuse 150 of the 172 rows. So
-- there is none; what COULD be done is here.
--
-- THE THREE MONEY COLUMNS ARE NON-NEGATIVE. Before that the schema guarded \`amount > 0\` and nothing
-- else about money, and that was measurably wrong: \`eventsNetByAccount\` computes \`-(amount + unitFee)\`
-- on an outgoing transfer, so a negative \`unitFee\` RAISED the balance. Transferring one sat with a fee of
-- -50,000 made the balance 50,000 larger: money from nothing, without an error.
--
-- WHAT IS DELIBERATELY ABSENT is \`unitFee <= amount\`. The fee does not come off the amount but on top of
-- it, see the formula above. Sending a thousand sats with two thousand sats of cost is therefore a valid
-- booking, and a CHECK refusing it would make a real transaction unbookable.
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, -- NULL = no origin (buy, interest). NO ACTION: an account with history is not deletable
toAccountId INTEGER REFERENCES Account(id) ON DELETE NO ACTION, -- NULL = no destination (sell)
productId INTEGER REFERENCES MetalProduct(id) ON DELETE NO ACTION, -- NULL = no piece entry. NO ACTION and not RESTRICT: otherwise an Asset cascade fails on this
productCount INTEGER CHECK (productCount IS NULL OR productCount > 0), -- sits right behind productId because it only has meaning WITH it
importReference TEXT, -- NULL = entered manually; the dedup key of the import
externalAddress TEXT, -- NULL = no external address; the match key with WalletTransaction.address
note TEXT, -- NULL = no note
amount INTEGER NOT NULL CHECK (amount > 0), -- smallest unit; for product rows equal to productCount * MetalProduct.fineGrams
fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice >= 0), -- NULL = not applicable (transfer); required for buy/sell, see CHECK
fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0), -- NULL = no cost recorded
unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0), -- NULL = no cost in the asset itself; NO upper bound on amount, see the head
-- WHERE THIS PRICE COMES FROM, and NULL is a value: "unknown", which every booking from before this
-- column carries. Beside the money columns because it describes fiatPrice, and before the
-- timestamps because those go last (§6.7 group 7). Migration 88 therefore rebuilds the table.
priceOrigin TEXT CHECK (priceOrigin IS NULL OR priceOrigin IN (${PRICE_ORIGIN})),
type TEXT NOT NULL CHECK (type IN (${EVENT_TYPE})),
source TEXT NOT NULL CHECK (source IN (${EVENT_SOURCE})), -- how the row came to be; one of the three meanings of 'source'
occurredAt TEXT NOT NULL, -- ISO UTC; the old schema held local time without a zone here
createdAt TEXT NOT NULL,
updatedAt TEXT, -- NULL = never changed since it was created
-- The product pair is all or nothing. Counted on the backup: zero deviations.
CHECK ((productId IS NULL) = (productCount IS NULL)),
-- This was an app guard in assertEventPayload and belongs in the schema: a trade without a price is no
-- trade. Counted on the backup: all 76 buy/sell rows have a fiatPrice.
CHECK (type NOT IN ('buy','sell') OR fiatPrice IS NOT NULL),
-- A reservation (migration 126) sits on exactly one account and carries nothing a booking prices or
-- matches on: booking it turns this row into a transfer, and only then may those columns fill.
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;
CREATE INDEX IxEventAssetId ON Event(assetId); -- FK scan on asset delete + query: positions per asset
CREATE INDEX IxEventFromAccountId ON Event(fromAccountId); -- FK scan on account delete + query: eventsNetByAccount
CREATE INDEX IxEventToAccountId ON Event(toAccountId); -- idem
CREATE INDEX IxEventProductId ON Event(productId); -- FK scan on product delete + query: metal holdings per product
CREATE INDEX IxEventOccurredAt ON Event(occurredAt); -- query: listEvents sorts on this
CREATE INDEX IxEventType ON Event(type); -- query: listTrades/listTransfers filter on this
CREATE INDEX IxEventExternalAddress ON Event(externalAddress); -- query: the address matching of the reconcile layer
CREATE INDEX IxEventImportReference ON Event(importReference); -- query: findEventByRef, the dedup of the import
-- Valuation: a manual or scanned balance on a reference date. \`valuedOn\` was called \`onDate\` and follows
-- the date rule: a column on \`...On\` carries a CALENDAR DATE (YYYY-MM-DD), a column on \`...At\` a
-- timestamp in ISO UTC. Here they sit side by side, and that is exactly why that rule exists:
-- \`valuedOn\` is the reference date, \`rateAt\` the moment the rate applied.
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, -- NULL = the whole portfolio; NO ACTION because SET NULL would silently make an account valuation portfolio-wide
balance INTEGER NOT NULL,
fiatRate REAL NOT NULL,
source TEXT NOT NULL CHECK (source IN (${VALUATION_SOURCE})),
valuedOn TEXT NOT NULL, -- calendar date
rateAt TEXT NOT NULL, -- timestamp
-- The gap with the ledger has been seen and accepted: no book button, no card, and it no longer counts
-- as unexplained. A timestamp and not a flag, after the model of Notification.dismissedAt: when you
-- accepted something is usable, "yes" is not.
gapAcceptedAt TEXT,
createdAt TEXT, -- NULL = row predates migration 85
updatedAt TEXT, -- NULL = never changed since it was created
UNIQUE (assetId, valuedOn, accountId) -- one valuation per (asset, date, account); this is the collision mergeAccounts must resolve
) STRICT;
CREATE INDEX IxValuationAccountId ON Valuation(accountId); -- FK scan on account delete + query: valuations per account
-- ═══ Reconcile layer ═══════════════════════════════════════════════
-- ScanLog: the memory of every scan. rawData is an audit blob (JSON text) and is never searched; that is
-- the criterion for why it may stay a blob.
CREATE TABLE ScanLog (
id INTEGER PRIMARY KEY,
accountId INTEGER NOT NULL REFERENCES Account(id) ON DELETE CASCADE,
addressesFound INTEGER CHECK (addressesFound IS NULL OR addressesFound >= 0), -- NULL = the scan did not get that far
balanceFound INTEGER CHECK (balanceFound IS NULL OR balanceFound >= 0), -- a snapshot in sats; NULL = idem
proposalsCreated INTEGER CHECK (proposalsCreated IS NULL OR proposalsCreated >= 0), -- NULL = idem
rawData TEXT, -- NULL = no raw output kept
status TEXT NOT NULL CHECK (status IN (${SCAN_STATUS})),
scannedAt TEXT NOT NULL
) STRICT;
CREATE INDEX IxScanLogAccountIdScannedAt ON ScanLog(accountId, scannedAt); -- FK scan on account delete (accountId is first) + query: latestScanLog per account
-- ReconcileProposal: one detected movement without a counterpart in Event.
--
-- The column scanLogId was DROPPED at review. It was in both INSERTs (proposals.js and tx-links.js) but
-- both with a literal NULL, and nothing ever read it back: the column could never carry a value. That
-- also cleared the "second route to the same account" on this side, which ran through the scan log here.
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, -- NULL = a scan proposal without a movement; without a movement a link proposal is pointless
resolvedEventId INTEGER REFERENCES Event(id) ON DELETE SET NULL, -- NULL = not handled yet; SET NULL keeps the proposal as history
transactionHash TEXT, -- NULL = no hash known
amount INTEGER NOT NULL CHECK (amount > 0), -- smallest unit; the direction sits in \`direction\`
direction TEXT NOT NULL CHECK (direction IN (${DIRECTION})),
suggestedType TEXT NOT NULL CHECK (suggestedType IN (${SUGGESTED_TYPE})),
status TEXT NOT NULL CHECK (status IN (${PROPOSAL_STATUS})),
detectedAt TEXT NOT NULL
) STRICT;
CREATE INDEX IxReconcileProposalAccountId ON ReconcileProposal(accountId); -- FK scan on account delete + query: proposals per account
CREATE INDEX IxReconcileProposalAssetId ON ReconcileProposal(assetId); -- FK scan on asset delete
CREATE INDEX IxReconcileProposalWalletTransactionId ON ReconcileProposal(walletTransactionId); -- FK scan on movement delete
CREATE INDEX IxReconcileProposalResolvedEventId ON ReconcileProposal(resolvedEventId); -- FK scan on event delete
CREATE INDEX IxReconcileProposalStatus ON ReconcileProposal(status); -- query: the pending list of the reconcile widget
-- Idempotency of the link proposals: at most one row per (movement, event). Partial, because scan
-- proposals without a movement may exist several times. Note the NULL semantics: two rows (5, NULL) do
-- not collide, so this does not protect syncTxProposals against duplicates: the preceding DELETE does
-- that work.
CREATE UNIQUE INDEX UxReconcileProposalLink ON ReconcileProposal(walletTransactionId, resolvedEventId) WHERE walletTransactionId IS NOT NULL;
-- WalletTransactionEventLink: a confirmed link between a movement and an event. Not 1:1: with address
-- reuse one movement may hang on several events.
CREATE TABLE WalletTransactionEventLink (
id INTEGER PRIMARY KEY,
walletTransactionId INTEGER NOT NULL REFERENCES WalletTransaction(id) ON DELETE CASCADE,
eventId INTEGER NOT NULL REFERENCES Event(id) ON DELETE CASCADE,
method TEXT NOT NULL CHECK (method IN (${LINK_METHOD})), -- HOW it was linked; an established term, stays 'method'
createdAt TEXT NOT NULL,
UNIQUE (walletTransactionId, eventId) -- underpins INSERT OR IGNORE in syncAddressLinks
) STRICT;
CREATE INDEX IxWalletTransactionEventLinkEventId ON WalletTransactionEventLink(eventId); -- FK scan on event delete + query: links per event
-- No separate index on walletTransactionId: UNIQUE(walletTransactionId, eventId) already has it first.
-- ═══ Import ════════════════════════════════════════════════════════
-- ImportBatch: one CSV import. \`rawData\` was called \`raw\` and now has the same name as the audit blob of
-- ScanLog: two names for one concept was a reading trap.
--
-- \`origin\` STAYS, a correction on the first review list. That called the column dead because it is NULL
-- in all ten rows on the backup, but that looked at the DATA and not at the code: the import page has an
-- input field "Herkomst" that fills it. Never used is something else than unusable.
CREATE TABLE ImportBatch (
id INTEGER PRIMARY KEY,
accountId INTEGER REFERENCES Account(id) ON DELETE SET NULL, -- NULL = the batch does not concern one account; SET NULL because the import stays valid as history
filename TEXT NOT NULL,
rowCount INTEGER NOT NULL CHECK (rowCount >= 0),
rawData TEXT, -- NULL = the raw CSV was not kept
origin TEXT, -- NULL = origin not filled in by the user
importedAt TEXT NOT NULL
) STRICT;
CREATE INDEX IxImportBatchAccountId ON ImportBatch(accountId); -- FK scan on account delete
-- ═══ Price history ═════════════════════════════════════════════════
-- PriceHistory: the text source became a real FK. ONE price column, in the unit of account of the
-- portfolio; the second column \`usd\` went in migration 107: a second currency on
-- screen comes from the exchange-rate series, not from what a source happened to deliver in dollars.
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, -- was the text \`source\`; RESTRICT: a provider with price rows is not deletable
fiatPrice REAL NOT NULL, -- in the unit of account; a row without a price is no row
resolution TEXT NOT NULL CHECK (resolution IN (${PRICE_RESOLUTION})), -- had no CHECK at all in the old schema
observedAt TEXT NOT NULL, -- ISO UTC
sourceName TEXT, -- NULL = the provider itself; on a row of Atreides Services the source it named (its alias)
-- THREE MOMENTS THAT ARE NOT THE SAME, and that is exactly why this table was rebuilt instead of
-- exempted. observedAt is when the RATE APPLIED; createdAt is when this ROW came to be, and when
-- fetching history those lie years apart; updatedAt is when a better source overwrote it. Leaving one
-- out because there is already a timestamp would pass one moment off as another.
createdAt TEXT, -- NULL = row predates migration 86
updatedAt TEXT, -- NULL = never overwritten since the first write
UNIQUE (assetId, providerId, observedAt, resolution) -- underpins the idempotent price upsert
) STRICT;
CREATE INDEX IxPriceHistoryProviderId ON PriceHistory(providerId); -- FK scan on provider delete
CREATE INDEX IxPriceHistoryAssetIdObservedAt ON PriceHistory(assetId, observedAt); -- query: the series per asset over time (signals, charts, box 3)
-- ═══ Interest ══════════════════════════════════════════════════════
-- InterestSchedule: a deposit-like agreement per account. rateKind decides whether principalAmount and
-- annualRate have meaning.
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, -- NULL = no fixed principal; NO ACTION so a cascade through payoutAssetId may clear it first
payoutAssetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE, -- without a payout asset the agreement has no meaning
note TEXT, -- NULL = no note
principalAmount INTEGER CHECK (principalAmount IS NULL OR principalAmount > 0), -- NULL = not applicable (variable)
annualRate REAL CHECK (annualRate IS NULL OR annualRate >= 0), -- NULL = not applicable (variable)
rateKind TEXT NOT NULL CHECK (rateKind IN (${RATE_KIND})),
creditCadence TEXT CHECK (creditCadence IS NULL OR creditCadence IN (${CREDIT_CADENCE})), -- NULL = no crediting rhythm
creditWeekday INTEGER CHECK (creditWeekday IS NULL OR creditWeekday BETWEEN 0 AND 6), -- NULL = no weekly rhythm
creditDayOfMonth INTEGER CHECK (creditDayOfMonth IS NULL OR creditDayOfMonth BETWEEN 1 AND 31), -- NULL = no monthly rhythm
startsOn TEXT NOT NULL, -- calendar date
endsOn TEXT, -- NULL = running
compounding INTEGER NOT NULL CHECK (compounding IN (0,1)), -- 1 = each credit also earns over the credits before it
createdAt TEXT NOT NULL,
updatedAt TEXT, -- NULL = never changed since it was created
-- The three conditional rules that until now stood only in comments: a 'variable' agreement could
-- silently carry a principal AND a rate. All three counted on the backup: zero deviations.
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),
-- Interest on interest needs a fixed principal in the payout asset: interest in another asset cannot
-- be added to it, and a balance-following principal already grows by every credit booked on it.
CHECK (compounding = 0 OR (rateKind = 'fixed' AND principalAmount IS NOT NULL AND principalAssetId = payoutAssetId))
) STRICT;
CREATE INDEX IxInterestScheduleAccountId ON InterestSchedule(accountId); -- FK scan on account delete + query: agreements per account
CREATE INDEX IxInterestSchedulePrincipalAssetId ON InterestSchedule(principalAssetId); -- FK scan on asset delete
CREATE INDEX IxInterestSchedulePayoutAssetId ON InterestSchedule(payoutAssetId); -- FK scan on asset delete
-- InterestPeriod: an INTEREST PERIOD within an agreement (the rate is fixed but changes per date).
--
-- WHY ITS OWN TABLE AND NOT A SECOND AGREEMENT: a savings account with a changing rate is one agreement
-- with one principal and one rhythm; only the percentage shifts. Two agreements side by side would cut
-- the credits in two and make the forecast count twice.
--
-- \`InterestSchedule.annualRate\` STAYS THE RATE FROM \`startsOn\`; these rows are the changes AFTER it. So
-- nothing changes for an agreement with one rate and no data migration is needed. The CHECK on startsOn
-- cannot see the start date of the agreement (that sits in another table), so that bound lives in the
-- data layer; what is here is what the table itself can guarantee.
CREATE TABLE InterestPeriod (
id INTEGER PRIMARY KEY,
scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE,
startsOn TEXT NOT NULL, -- calendar date: this rate applies from this day; it runs to the next period or the end of the agreement
annualRate REAL NOT NULL CHECK (annualRate >= 0),
createdAt TEXT, -- NULL = row predates migration 85
updatedAt TEXT, -- NULL = never changed since it was created
UNIQUE (scheduleId, startsOn) -- two rates on the same day is not a state
) STRICT;
-- InterestCredit: the handled credits. 'due' is computed virtually; only the decisions are here.
CREATE TABLE InterestCredit (
id INTEGER PRIMARY KEY,
scheduleId INTEGER NOT NULL REFERENCES InterestSchedule(id) ON DELETE CASCADE,
eventId INTEGER REFERENCES Event(id) ON DELETE CASCADE, -- NULL = not booked (dismissed); on rolling back an event the credit goes with it
status TEXT NOT NULL CHECK (status IN (${CREDIT_STATUS})),
periodStart TEXT NOT NULL, -- calendar date
periodEnd TEXT NOT NULL, -- calendar date
createdAt TEXT NOT NULL,
updatedAt TEXT, -- NULL = never changed since it was created
UNIQUE (scheduleId, periodEnd), -- one decision per period: makes booking and dismissing idempotent
CHECK (periodEnd >= periodStart)
) STRICT;
CREATE INDEX IxInterestCreditEventId ON InterestCredit(eventId); -- FK scan on event delete
-- ═══ Notifications ═════════════════════════════════════════════════
-- Notification: the high-water model. At most one active row per kind (dismissedAt NULL); highWater is
-- the monotone marker above which a new notification arises.
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 (${NOTIFICATION_KIND})),
dismissedAt TEXT, -- NULL = an active notification
createdAt TEXT NOT NULL -- sits AFTER dismissedAt: createdAt belongs last, even when the reading order feels different
) STRICT;
CREATE INDEX IxNotificationKind ON Notification(kind); -- query: looking up the active notification per kind
-- ═══ Source matrix ═════════════════════════════════════════════════
-- PriceSource: which provider delivers an asset per source choice (spot, history). rank 1..N is the
-- preference chain, rank NULL means explicitly excluded, no row means not filled in. One choice per asset
-- for every consumer (migration 97; it replaced ConsumerSource, which had a chain per consumer).
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, -- RESTRICT: a provider still in a chain is not deletable
capability TEXT NOT NULL CHECK (capability IN (${SOURCE_CAPABILITY})),
rank INTEGER CHECK (rank IS NULL OR rank > 0), -- NULL = explicitly excluded
status TEXT NOT NULL CHECK (status IN (${SOURCE_STATUS})),
createdAt TEXT, -- NULL = row predates migration 85
UNIQUE (assetId, capability, providerId), -- one row per cell per provider: underpins the idempotent seed
UNIQUE (assetId, capability, rank) -- one provider per rank within a chain
) STRICT;
CREATE INDEX IxPriceSourceProviderId ON PriceSource(providerId); -- FK scan on provider delete (RESTRICT must find the child rows); assetId leads both UNIQUEs, so it needs no index of its own
-- ═══ Dashboard ═════════════════════════════════════════════════════
-- DashboardProfile: a stored layout to switch between. The word 'profile' here is the stored layout; the
-- characterisation of the user is called 'persona' in shared/config.js, otherwise two concepts carry one
-- word.
CREATE TABLE DashboardProfile (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
searchName TEXT NOT NULL,
description TEXT, -- NULL = not filled in
sortOrder INTEGER NOT NULL CHECK (sortOrder >= 0), -- the order in the picker; an ordering here, unlike with the widgets
isActive INTEGER NOT NULL CHECK (isActive IN (0,1)),
isSystem INTEGER NOT NULL CHECK (isSystem IN (0,1)), -- 1 = the system profile: not deletable, always a working dashboard
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
UNIQUE (searchName) -- two profiles with the same name is a mistake, not a wish
) STRICT;
-- At most one active profile. "Exactly one" is thereby NOT enforced (zero active profiles stays
-- possible) and cannot be, with an index or a CHECK: it is a statement about the whole table. That
-- invariant belongs in the data layer, with a test.
CREATE UNIQUE INDEX UxDashboardProfileActive ON DashboardProfile(isActive) WHERE isActive = 1;
-- DashboardWidget: one widget instance within a profile. Deliberately NO sortOrder: gridX/gridY are
-- authoritative and a second ordering would drift from them.
--
-- TWO DEVIATIONS FROM THE FIRST DDL, both because the shape that stood there could not express two
-- things:
--
-- 1. \`spanHeight\` became nullable. The engine knows a state "height not measured yet" (on restore
-- default, a new widget, a width change) and wrote 0 for it. That 0 could not pass
-- \`CHECK (spanHeight > 0)\`, and a 0 in a grid measure is moreover a second meaning in a column where
-- every other value is a real measure. NULL says it properly.
-- 2. A column \`settings\` was added for the widget-specific parameters that are NOT a reference. Reason:
-- the clocks widget stores a city list and a checkbox per instance, and those fitted nowhere; on the
-- conversion that setting would have vanished silently. The criterion: a blob that is never queried
-- is storage, a blob the app REFERS through is a missed table. This one is queried by no query and
-- goes as a whole to one widget.
-- **\`assetId\` DOES stay a real column with an FK**, for exactly the stated reason: in JSON the
-- database guards nothing, and then a profile keeps a price widget pointing at a deleted asset. The
-- boundary is therefore: a parameter that points at another table is a column, the rest is storage.
-- And \`assetId\` is NOT also in \`settings\`: two places asserting the same is derived duplication.
CREATE TABLE DashboardWidget (
id INTEGER PRIMARY KEY,
profileId INTEGER NOT NULL REFERENCES DashboardProfile(id) ON DELETE CASCADE,
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, -- NULL = this widget does not hang on one asset; CASCADE so a deleted asset takes its price widget with it
instanceKey TEXT NOT NULL, -- the stable key of this instance within the profile
gridX INTEGER NOT NULL CHECK (gridX >= 0),
gridY INTEGER NOT NULL CHECK (gridY >= 0),
spanWidth INTEGER NOT NULL CHECK (spanWidth > 0), -- a width is chosen, so always known
spanHeight INTEGER CHECK (spanHeight IS NULL OR spanHeight > 0),-- NULL = not measured yet (a height is measured, not chosen)
type TEXT NOT NULL, -- DELIBERATELY NO CHECK: the widget list grows and a CHECK is immutable, so every new widget would cost a table rebuild. Validation in the data layer
settings TEXT, -- NULL = none; otherwise JSON with the parameters that are NOT a reference (clocks: cities/showExchanges). assetId is deliberately not in here
createdAt TEXT, -- NULL = row predates migration 85
UNIQUE (profileId, instanceKey) -- one instance per key within a profile
) STRICT;
CREATE INDEX IxDashboardWidgetAssetId ON DashboardWidget(assetId); -- FK scan on asset delete
-- ═══ System ════════════════════════════════════════════════════════
-- Preference: named preferences (app settings and UI state). scope and key are the primary key; the
-- second of the three tables without a surrogate id. The registry in shared/config.js is the SSOT for
-- allowed AND initial values. Scope and key VALUES fall outside the naming convention: that is data.
CREATE TABLE Preference (
scope TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL, -- the JSON serialisation of one value
createdAt TEXT, -- NULL = row predates migration 85
PRIMARY KEY (scope, key) -- underpins setPref with ON CONFLICT
) STRICT;
-- AiToolCall: the audit log of the MCP server. One row per tool call, a REFUSED one included: a refused
-- call in particular proves the boundary works, and the user must be able to read that. Append-only; the
-- data layer has no update and no delete path.
--
-- WHAT IS DELIBERATELY ABSENT is the outgoing payload itself. Storing that in full would put the
-- sensitive composition on disk after all, only somewhere else, and this is precisely the file you want
-- to be able to show someone. What IS in it is enough to account: which tool, which fields, how many
-- characters and with what outcome.
--
-- \`fieldsShared\` is the column that makes the promise checkable instead of promised: a JSON array with
-- the FIELD NAMES the translator let through, not their values. If a name is in there that should not be,
-- that can be read without opening the code.
--
-- \`serverVersion\` SITS LAST AND NOT NEXT TO \`client\`, where it belongs by content. Reason: it came in
-- with migration 62 and SQLite can only add a column at the end. A table rebuild purely for the reading
-- order is not worth that price.
--
-- WHY THIS COLUMN EXISTS, and it is not bookkeeping for its own sake. MCP over stdio means one server
-- process PER CLIENT, each with the code of the moment that client started. Two connected clients are
-- therefore two servers on this database, possibly with different versions. A process from before the
-- on/off gate (server/ai/gate.js) does not read that switch, and without this column that cannot be seen
-- afterwards: such a call stands as 'Uitgevoerd' in the log while the user thought the door was closed.
-- So NULL means something here: "handled by a server that did not know the switch yet".
--
-- \`sessionId\` and \`client\` come from the MCP handshake and are free text without an FK: a session exists
-- only as long as the process lives and has no table. \`client\` is what the client says about itself (a
-- desktop app, an agent CLI), and that is exactly what the user wants to see in the UI now that the
-- server can be connected to an arbitrary client.
CREATE TABLE AiToolCall (
id INTEGER PRIMARY KEY,
sessionId TEXT NOT NULL,
client TEXT, -- NULL = the client did not name itself at the handshake
tool TEXT NOT NULL, -- the name as the client called it; an UNKNOWN name is therefore logged too
arguments TEXT, -- NULL = without arguments; JSON, and meant for reading back the KEYS
fieldsShared TEXT, -- NULL = nothing went out; otherwise a JSON array of field names
charactersOut INTEGER CHECK (charactersOut IS NULL OR charactersOut >= 0), -- NULL = nothing sent
outcome TEXT NOT NULL CHECK (outcome IN (${AI_CALL_OUTCOME})),
detail TEXT, -- NULL = nothing special; on error/refused the reason in user language
occurredAt TEXT NOT NULL,
serverVersion TEXT -- NULL = handled by an MCP server from before migration 62; see above
) STRICT;
CREATE INDEX IxAiToolCallOccurredAt ON AiToolCall(occurredAt); -- query: the last N rows for the AI tab, newest first
-- AiToolCallMessage: the conversation of one call. One row per MOMENT, and that is why it is a table and
-- not a couple of extra columns: the user wanted to see WHAT went over the line WHEN, and one row per
-- call cannot express that.
--
-- THE OUTGOING TEXT IS HERE, and that does not contradict the head of AiToolCall above but refines it.
-- The argument there was that the sensitive composition would otherwise end up on disk after all; that
-- does not hold for this text, because it has already been through the translator. What lies here holds
-- handles and bucketed amounts and is therefore LESS sensitive than the database underneath. And without
-- this text \`fieldsShared\` is a claim; with it, it is the evidence.
--
-- WHAT IS STILL ABSENT is the translation table (\`account:a1\` to which account). That mapping was not
-- part of the event, and an audit trail that supplements the file with what did not belong in it has
-- become a reconstruction. The way back runs through a secret outside the database.
--
-- \`text\` IS NOT NULL AND MAY BE EMPTY, a deliberate distinction: a moment without text exists (a call
-- without arguments), but a moment whose content we do not know does not. The text is truncated at
-- CONFIG.AI.LIMITS.MAX_LOG_CHARS with a visible marker in it; truncating silently would let a half
-- answer read as a whole one.
--
-- ON DELETE CASCADE is not a cleanup path here but a shape requirement: AiToolCall has no delete
-- function, so this rule cannot fire. RESTRICT would suggest a deletion is to be expected somewhere.
-- CASCADE says: this conversation exists ONLY with that call.
CREATE TABLE AiToolCallMessage (
id INTEGER PRIMARY KEY,
callId INTEGER NOT NULL REFERENCES AiToolCall(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN (${AI_MESSAGE_KIND})),
text TEXT NOT NULL, -- empty is allowed; NULL is not (see above)
occurredAt TEXT NOT NULL
) STRICT;
CREATE INDEX IxAiToolCallMessageCallId ON AiToolCallMessage(callId); -- FK index + the query of the expander: the conversation of one call
-- BusSignal: the signal bus. One row is one nudge: something changed, this was its name, that was when.
-- Contentless and idempotent.
--
-- WHY THIS TABLE EXISTS. The data layer reports a change with \`emitChange(name)\`, and
-- \`server/data/_events.js\` collects those names in a Set in the memory of one process; the RPC layer
-- passes them along in the response. That works exactly as long as the browser made the request ITSELF.
-- With a second process (the MCP server) that no longer holds: it writes into a basket nobody looks at.
-- This table is where a nudge DOES cross a process boundary.
--
-- \`name\` IS THE EVENT NAME THE BROWSER DISPATCHES ('providers-changed'), so not free text but what the
-- existing listeners already expect. No call site of \`emitChange\` changes because of this.
--
-- THERE IS DELIBERATELY NO COLUMN FOR A PAYLOAD. As soon as a nudge carries an id or an amount, the three
-- properties the whole setup rests on lapse: merging is allowed, receiving twice is harmless, and
-- discarding is allowed. What carries meaning is not a nudge but a command, and that gets its own table
-- WITH a state.
--
-- THIS TABLE IS CLEANED UP, unlike AiToolCall above. That is not an inconsistency but the difference
-- between a nudge and an accounting: a discarded nudge loses nothing, a discarded audit row is a gap in
-- the trail.
--
-- NO INDEX, a choice with two halves. The read question ("everything with an id greater than N") runs
-- over the primary key, and in SQLite that IS the rowid; nothing to gain there. The cleanup DOES look at
-- \`occurredAt\` and therefore scans, and that is fine because this table stays small precisely BY that
-- cleanup: building an index to speed up the cleanup that keeps the table small costs write work on
-- EVERY insert to save a scan over a handful of rows.
--
-- THE CURSOR IS THE ID AND NOT THE TIME, independent of the above: two nudges in the same millisecond
-- cannot be ordered by time. That has been repaired three times in this project (scan logs, the audit
-- log, the conversation log). The cleanup MAY work on time, because there the order is not the question
-- but the age.
CREATE TABLE BusSignal (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL, -- the event name the browser dispatches
occurredAt TEXT NOT NULL -- only to be able to clean up and to read what was when
) STRICT;
-- Command: the command inbox. One row is one command from outside the browser: now only navigation,
-- later also a write proposal from the AI.
--
-- WHY THIS IS NOT A \`BusSignal\`. A nudge may be lost, may arrive twice and is cleaned up; a command may
-- do none of those three. It carries meaning (where should you go), it may fire only once, and it has an
-- outcome. That is a life cycle, and a life cycle belongs in its own table with a state. The bus reports
-- ONLY that something is ready.
--
-- \`payload\` CARRIES THE MEANING, unlike BusSignal, which deliberately has no payload column. For
-- \`navigate\` it holds a target (route, tab, subtab), in the form of the target registry. JSON and not
-- columns per field: the second kind (a write proposal) carries something entirely different, and then
-- half the columns would always be empty.
--
-- THE CLAIM MODEL SITS IN \`state\` + \`claimedBy\`, and it is there because two open tabs both poll. A
-- nudge may be picked up by both; a command that fires twice jumps your screen twice. The tab that claims
-- it first executes it; the other sees 'claimed' and leaves it.
--
-- \`expiresAt\` COVERS TWO LIFETIMES WITH ONE COLUMN. A navigation command is volatile: with no tab open it
-- is pointless within a minute. A write proposal is durable and belongs on the action list; that will get
-- NULL. Only a command WITHOUT an expiry yields a card.
--
-- NO INDEX: the same trade-off as with BusSignal. The only query is "what is open and not yet expired",
-- and this table stays small because handled rows are cleaned up.
CREATE TABLE Command (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN (${COMMAND_KIND})),
payload TEXT, -- NULL = no payload; otherwise JSON, different per kind
state TEXT NOT NULL CHECK (state IN (${COMMAND_STATE})),
claimedBy TEXT, -- NULL = not picked up yet; otherwise the key of the tab
claimedAt TEXT,
expiresAt TEXT, -- NULL = does not expire (a proposal on the action list)
detail TEXT, -- NULL = nothing special; otherwise the reason for rejected/expired
resolvedAt TEXT, -- NULL = not handled yet
createdAt TEXT NOT NULL -- last, as the column order prescribes
) STRICT;
-- AiProposal: what an AI PROPOSES, waiting for a human.
--
-- WHY ITS OWN TABLE AND NOT ReconcileProposal. That table is firmly typed on its own domain: accountId
-- and assetId are NOT NULL, there is an amount > 0, a direction in/out, and two CHECK enums with
-- reconcile values. A price-point proposal has no account, no direction and no quantity. Six constraints
-- would have to give way, and those are exactly the constraints that make a wrong proposal impossible
-- there.
--
-- WHY ALSO NOT AS A THIRD Command.kind. That table is an inbox with a life cycle of seconds: claim,
-- expire, exactly one tab executes. A proposal is truth that stands until the user has an opinion about
-- it. One table with two life cycles means EVERY query has to keep watching expiresAt.
--
-- NO GENERIC PAYLOAD, and that is the most important choice here. There is no payload blob with "run this
-- data-layer function": the card must show the concrete consequences in real values, and that is only
-- possible when the app knows what kind of proposal it is. Such a blob would also put one confirmation
-- over an unbounded write surface. So the columns are NAMED (assetId, fiatPrice and observedAt are called the
-- same as in PriceHistory, where they end up) and made required per kind with a CHECK.
--
-- Adding a kind is therefore a visible decision: a row in ENUMS.AI_PROPOSAL_KIND, the columns it needs, a
-- CHECK branch, and a line in the card. That is more work than a blob and that is intended.
--
-- ── SEVEN KINDS, AND THE PRICE OF THE RULE ABOVE IS PAID ──
-- \`account\` and \`event\` were added because one kind is not enough to propose anything meaningful: a
-- price point can only exist on an asset that already has bookings. With an account and an event a series
-- of proposals can build an administration from nothing, which is what test and demonstration data needs.
--
-- COLUMNS ADDED AND NO BLOB, because the reason above holds in full: the card shows the concrete
-- consequences in real values, and that is only possible when the app knows the fields. What IS reused
-- are \`assetId\`, \`fiatPrice\` and \`observedAt\`: on an event those are the price per unit and the moment, the
-- same meaning as on a price point. What got a column of its own is what has a meaning of its own.
--
-- \`eventType\` AND \`accountKind\` ARE NOT CALLED \`type\` AND \`kind\`, and that is not taste: \`kind\` is
-- already the kind of the PROPOSAL here. Two things called \`kind\` in one row makes every query about it
-- ambiguous.
--
-- \`accountId\` IS A FOURTH ACCOUNT COLUMN AND NOT A REUSE OF \`toAccountId\`, the only real choice in that
-- extension. Those two existing columns carry a DIRECTION (where does it come from, where does it go);
-- this one carries a POINTER (which row is this proposal about). Were the proposal to ride on
-- \`toAccountId\`, that column would mean two things and every query would have to keep watching the kind
-- to know which of the two.
--
-- THE CHECK BRANCHES PER KIND ARE NOT THE SAME AS THOSE OF THE DESTINATION, and that is deliberately
-- stricter: \`Event.fiatPrice\` may be 0, \`fiatPrice\` here may not (that column also carries the rate of a price
-- point, and a rate of zero is an empty row there). A purchase for zero euro is therefore not proposable;
-- that is a gift and not a trade, and it should be booked by hand.
CREATE TABLE AiProposal (
id INTEGER PRIMARY KEY,
assetId INTEGER REFERENCES Asset(id) ON DELETE CASCADE, -- NULL = a kind that points at no asset
accountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, -- NULL = a kind that POINTS AT no account; see below
fromAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, -- NULL = no origin (buy, interest, and every kind without an account)
toAccountId INTEGER REFERENCES Account(id) ON DELETE CASCADE, -- NULL = no destination (sell)
-- ── WHAT AROSE OUT OF THIS PROPOSAL ──
-- Of every row it must be readable how it came to be. That was impossible before: an approved proposal
-- landed as an ordinary row and this table only knew THAT it was accepted, not WHAT it became. The
-- chain could be walked in neither direction.
--
-- NAMED COLUMNS AND NOT A POLYMORPHIC PAIR (tableName plus rowId). The same choice as with the kind
-- fields below: the app should know what is there. It also yields a real FK, and therefore ON DELETE
-- SET NULL: if the row disappears, the reference disappears and the proposal stays as history. CASCADE
-- would erase the proposal, exactly the trail this is meant to keep.
--
-- THEY SIT IN THE FK BLOCK AND NOT LAST, and that was a correction: the first version added them with
-- ALTER TABLE (three lines instead of a rebuild) and that always puts a column last. That broke the
-- column-order rule, and that rule is worth it: references belong as a block directly behind id. So
-- the migration is a rebuild after all.
--
-- THERE IS NO createdPricePointId, AND THAT IS A DECISION. A price point goes through
-- upsertPriceHistory, and that does not necessarily create a row: if a point already exists on that
-- day from that source, it is updated. A column named "this proposal made this row" would assert
-- something untrue there, and a half-true trail is worse than no trail.
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 (${AI_PROPOSAL_KIND})),
name TEXT, -- NULL = a kind that names nothing; otherwise the name of the new row
accountKind TEXT CHECK (accountKind IS NULL OR accountKind IN (${ACCOUNT_KIND})),
isOwnCustody INTEGER CHECK (isOwnCustody IS NULL OR isOwnCustody IN (0,1)),
eventType TEXT CHECK (eventType IS NULL OR eventType IN (${LEDGER_EVENT_TYPE})), -- no reservation: the AI proposes bookings only
amount INTEGER CHECK (amount IS NULL OR amount > 0), -- smallest unit, like Event.amount
fiatPrice REAL CHECK (fiatPrice IS NULL OR fiatPrice > 0), -- NULL = a kind without an amount
-- The cost of a trade, in euros. NULL = no cost recorded, exactly as with Event.fiatFee.
fiatFee REAL CHECK (fiatFee IS NULL OR fiatFee >= 0),
-- The cost in the asset itself, smallest unit, on top of amount, as Event.unitFee (migration 125, plan
-- Bewijzen B6). The ledger subtracts it on a sale and on an outgoing transfer; without it an AI-proposed
-- send left the balance too high by the fee. See the CHECK below for where it may stand.
unitFee INTEGER CHECK (unitFee IS NULL OR unitFee >= 0),
-- The position of a balance proposal, in the smallest unit. NULL = a kind without a balance. ITS OWN
-- COLUMN AND NOT amount, and that is the whole reason it exists: amount requires > 0 because a booking
-- of zero is an error, while a BALANCE of zero is meaningful ("this account is empty now", and
-- Valuation.balance therefore allows it too). Giving the same column two meanings would require the
-- CHECK to read differently per kind.
balance INTEGER CHECK (balance IS NULL OR balance >= 0),
observedAt TEXT, -- NULL = a kind without a moment
-- The preference kind. Three columns named exactly as where they end up (\`Preference.scope\`, \`.key\`,
-- \`.value\`), just as \`assetId\`, \`fiatPrice\` and \`observedAt\` do for the other kinds. \`value\` carries JSON,
-- because that is what that column in Preference carries; the registry in shared/config.js validates
-- at execution.
--
-- THIS IS THE FIRST KIND THAT CREATES NOTHING BUT ADJUSTS SOMETHING, and that is exactly why the bound
-- sits in the code: CONFIG.AI.PROPOSABLE_PREF_SCOPES excludes the ai scope. Without that rule a
-- proposal can widen its own rights or switch the translator off, and nobody reads a card saying "set a
-- preference" as "give me more rights".
scope TEXT, -- NULL = a kind that sets no preference
key TEXT,
value TEXT,
-- The unit of account of a proposed portfolio (migration 108). Its own column and not a
-- reuse of \`value\`: that carries JSON for a preference, and this is fixed once the portfolio exists.
currency TEXT CHECK (currency IS NULL OR currency GLOB '[A-Z][A-Z][A-Z]'), -- NULL = a kind that creates no portfolio
note TEXT, -- NULL = no note; otherwise WHY the AI proposes this
status TEXT NOT NULL CHECK (status IN (${AI_PROPOSAL_STATUS})),
detail TEXT, -- NULL = nothing special; otherwise why it failed
resolvedAt TEXT, -- NULL = not handled yet
createdAt TEXT NOT NULL, -- last, as the column order prescribes
-- Per kind the fields it NEEDS. Without this CHECK a half proposal is a valid row, and then it only
-- topples at execution time: precisely too late.
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)),
-- An event without an account is impossible: nothing would change balance. WHICH of the two it is
-- depends on the kind, and the data layer knows that; here it only says there must be one.
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))),
-- The same rule as in Event: a trade without a price is no trade.
CHECK (kind <> 'event' OR eventType NOT IN ('buy','sell') OR fiatPrice IS NOT NULL),
-- A fee in units only where the ledger reads one: on a sale or a transfer. On a purchase or interest it
-- would be stored and count nowhere.
CHECK (unitFee IS NULL OR (kind = 'event' AND eventType IN ('sell','transfer'))),
-- A deletion proposal points at exactly one row. See the paragraph about accountId above.
CHECK (kind <> 'account_delete' OR accountId IS NOT NULL),
-- A balance belongs to ONE account, a hard requirement here while Valuation.accountId allows NULL (= the
-- whole portfolio). Reason: valuationReconcilable returns false without an account, so a
-- portfolio-wide anchor is one no check ever passes. A balance the AI proposes should always be
-- checkable.
CHECK (kind <> 'valuation' OR (assetId IS NOT NULL AND accountId IS NOT NULL
AND balance IS NOT NULL AND observedAt IS NOT NULL)),
-- And the other way: only a balance proposal carries a balance. Without this rule a booking WITH a
-- balance is a valid row, and then there is a number in it that nobody executes any more.
CHECK (kind = 'valuation' OR balance IS NULL),
-- A portfolio proposal carries a name and a currency, both required: a portfolio without a name is a
-- directory without a name, and one without a currency cannot be set up. The name reuses the column of
-- the account proposal; what it may NOT carry sits in the data layer, because that is a list of fields
-- and not a shape. Only this kind carries a currency.
CHECK (kind <> 'portfolio' OR (name IS NOT NULL AND currency IS NOT NULL)),
CHECK (kind = 'portfolio' OR currency IS NULL),
-- A preference proposal carries all three fields or it is no preference proposal. And the other way
-- with it, as with \`balance\`: without that second rule a booking with a scope is a valid row, and then
-- there is a setting in it that nobody executes any more.
CHECK (kind <> 'preference' OR (scope IS NOT NULL AND key IS NOT NULL AND value IS NOT NULL)),
-- A source-choice proposal (migration 98) reuses those three: the choice, the provider key, the action.
-- A provider KEY and not an id, because the key is what the AI reads and it is stable.
CHECK (kind <> 'source_choice' OR (assetId IS NOT NULL AND scope IN (${SOURCE_CAPABILITY})
AND key IS NOT NULL AND value IN (${SOURCE_CHOICE_ACTION}))),
CHECK (kind IN ('preference','source_choice') OR (scope IS NULL AND key IS NULL AND value IS NULL))
) STRICT;
CREATE INDEX IxAiProposalStatus ON AiProposal(status); -- query: the open list for the card
CREATE INDEX IxAiProposalAssetId ON AiProposal(assetId); -- FK scan on asset delete
CREATE INDEX IxAiProposalAccountId ON AiProposal(accountId); -- FK scan on account delete
CREATE INDEX IxAiProposalFromAccountId ON AiProposal(fromAccountId); -- idem
CREATE INDEX IxAiProposalToAccountId ON AiProposal(toAccountId); -- idem
-- The three origin columns. They carry two loads: the FK scan on a delete (every FK should have an index,
-- and baseline.test.mjs checks that), and the question they exist for: "which proposal made this row" is
-- a lookup ON this column.
CREATE INDEX IxAiProposalCreatedEventId ON AiProposal(createdEventId);
CREATE INDEX IxAiProposalCreatedAccountId ON AiProposal(createdAccountId);
CREATE INDEX IxAiProposalCreatedValuationId ON AiProposal(createdValuationId);
-- Schema: the version bookkeeping of the migration runner. Was called schema_meta, and 'meta' is an
-- abbreviation of metadata, exactly the word the architecture rules forbid as a column name.
-- The table carries one row with one number, and \`Schema.version\` says that in full.
-- Two more things are new. The id column with CHECK (id = 1): with it there cannot be more than one
-- version row, and readVersion() need not guess which one it takes. And the column is called \`version\`
-- and not \`schemaVersion\`: the latter repeats the name of its own table.
CREATE TABLE Schema (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL CHECK (version > 0)
) STRICT;
-- Portfolio: the facts of THIS administration that hold for its whole life. One row, like \`Schema\`.
--
-- \`baseCurrency\` IS THE UNIT OF ACCOUNT: every stored amount (\`Event.fiatPrice\`, \`Valuation.fiatRate\`,
-- \`PriceHistory.fiatPrice\`, ...) is in this currency. An ISO 4217 code; which codes the app offers is a list in
-- the config, the CHECK only guards the shape, so extending that list needs no rebuild.
--
-- FIXED AFTER CREATION, and the two triggers make that a guard instead of an agreement. Changing the unit
-- of account would mean converting every stored amount at the rate of its own date, and that is no
-- UPDATE of one row. DELETE is refused for the same reason: without the row the administration has no
-- unit of account.
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 -- NULL = portfolio predates migration 104
) STRICT;
CREATE TRIGGER TrPortfolioUpdate BEFORE UPDATE ON Portfolio
BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END;
CREATE TRIGGER TrPortfolioDelete BEFORE DELETE ON Portfolio
BEGIN SELECT RAISE(ABORT, 'Portfolio is fixed after creation'); END;
-- ChangeLog: the change log. One row is one change to one row of the administration. Which tables take
-- part sits in \`CONFIG.DB_HISTORY.TRACKED\`, with a test saying a new table requires a choice.
--
-- NO INSERT ROW, a decision of the user: a new record has its own \`createdAt\` and that never changes
-- again. That saves one row per row in the whole administration. It is also why migrations 85 and 86 had
-- to come first: that reasoning only has a floor if EVERY participating table has that timestamp too.
--
-- \`tableName\` AND \`rowId\` WITHOUT AN FK, and that is the price of one log instead of eleven. An FK
-- cannot be polymorphic. What it costs is known: a DELETE row points at a row that no longer exists, and
-- that is exactly the intention here: the answer to "where did that booking go" is precisely that row.
--
-- \`changes\` IS A JSON BLOB, the exception in this project (generic fields are forbidden). The reason
-- \`AiProposal\` has none does not hold here: that is a proposal the app must be able to execute, this is a
-- log a human reads. Only the CHANGED fields go in and not the whole row, otherwise the log is a second
-- database and sensitive values sit on disk twice. One exception: on a delete the whole row goes in,
-- because there is no "new" there.
--
-- APPEND-ONLY, like \`AiToolCall\`: no update path, no delete path, and not in
-- \`CONFIG.DB_DELETABLE_TABLES\`. A log from which one row can be clicked away accounts for nothing.
CREATE TABLE ChangeLog (
id INTEGER PRIMARY KEY,
tableName TEXT NOT NULL, -- polymorphic: no FK possible
rowId INTEGER NOT NULL,
operation TEXT NOT NULL CHECK (operation IN (${CHANGE_OPERATION})),
origin TEXT NOT NULL CHECK (origin IN (${CHANGE_ORIGIN})),
originId INTEGER, -- NULL = that origin has no row of its own
changes TEXT NOT NULL, -- JSON: { field: [old, new] }, on delete the whole row
occurredAt TEXT NOT NULL
) STRICT;
CREATE INDEX IxChangeLogTableNameRowId ON ChangeLog(tableName, rowId); -- the question: what happened to THIS record
CREATE INDEX IxChangeLogOccurredAt ON ChangeLog(occurredAt); -- cleanup, and the timeline per period
-- HygieneAcceptance: a finding of the health report you have looked at and judged to be right.
--
-- WHY THIS IS A TABLE AND NOT A COLUMN. A valuation gap is accepted on the row itself
-- (\`Valuation.gapAcceptedAt\`), and that works because that finding IS a row. A hygiene finding is not:
-- it is a kind plus whatever the query grouped on, and for half the kinds that is an aggregate with no
-- row of its own (duplicate bookings, gaps in a price history). There is nothing to hang a column on.
--
-- \`fingerprint\` IS THE FINDING, CANONICALLY. It is a hash over the kind plus the finding's \`row\` and
-- \`counts\` exactly as the report produced them, so the acceptance matches again next time only when the
-- situation is IDENTICAL. That the counts are in it is the deciding choice: accept thirteen unlinked
-- movements and a fourteenth appears, and the finding returns with all fourteen. You judged a situation,
-- not a subject.
--
-- \`kind\` AND \`summary\` ARE THERE TO BE READ, not to match on. Without them a list of acceptances is a
-- page of hashes, and then there is no way back from "I no longer agree" to which finding it was.
--
-- NO FK ANYWHERE, for the same reason as \`ChangeLog\`: a finding can point at an event, an asset, a
-- wallet movement or at nothing at all, and an FK cannot be polymorphic.
CREATE TABLE HygieneAcceptance (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL, -- the finding kind, as \`hygieneReport\` names it
fingerprint TEXT NOT NULL, -- hash over kind + row + counts
summary TEXT NOT NULL, -- readable, so the list of acceptances says something
note TEXT, -- NULL = accepted without a reason
createdAt TEXT NOT NULL -- and NOT \`acceptedAt\`: the row exists BECAUSE it was accepted, so that would be two names for one moment
) STRICT;
CREATE UNIQUE INDEX UxHygieneAcceptanceFingerprint ON HygieneAcceptance(fingerprint); -- accepting twice is one row
CREATE INDEX IxHygieneAcceptanceKind ON HygieneAcceptance(kind); -- query: the acceptances of one kind
-- IgnoredPriceGap: a stretch of days without a price that the user chose to leave:
-- no source has it, and the manual price is not worth entering. A RANGE and not a fingerprint
-- like HygieneAcceptance, because a gap at the end of the history grows a day every day: a fingerprint
-- would change daily and bring it back. \`listPriceGaps\` counts these days as covered.
CREATE TABLE IgnoredPriceGap (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
fromDay TEXT NOT NULL, -- YYYY-MM-DD, the first day left alone
untilDay TEXT NOT NULL, -- YYYY-MM-DD, the last one
createdAt TEXT NOT NULL, -- and no \`ignoredAt\`: the row exists BECAUSE it was ignored
CHECK (fromDay <= untilDay)
) STRICT;
CREATE INDEX IxIgnoredPriceGapAssetId ON IgnoredPriceGap(assetId); -- query: the ignored ranges of one asset
-- PriceAbsence: a day a per-date source answered without a price (a holiday, a date before its history),
-- so the close round does not spend quota on it again. Per source, because
-- another source may have that day. Deleting a row makes the day be asked again.
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, -- YYYY-MM-DD
createdAt TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX UxPriceAbsenceAssetIdProviderIdDay ON PriceAbsence(assetId, providerId, day); -- once per day; also the lookup per asset
CREATE INDEX IxPriceAbsenceProviderId ON PriceAbsence(providerId); -- FK: deleting a provider
-- AssetNote: the user's own notes on one asset (26-09-2026), shown in the detail dialogs of Vermogen and
-- Prijsgeschiedenis alike. ONE TEXT PER ASSET and not a row per line: the dialog offers one free field
-- that can be edited anywhere; a stamped line is only a quick way to add to it. A table of its own and
-- not a column on Asset, so Asset needs no rebuild. Empty is no row.
CREATE TABLE AssetNote (
id INTEGER PRIMARY KEY,
assetId INTEGER NOT NULL REFERENCES Asset(id) ON DELETE CASCADE,
body TEXT NOT NULL, -- the text; not \`note\`, which would repeat the table name
createdAt TEXT NOT NULL -- no \`updatedAt\`: the table is not in the change log
) STRICT;
CREATE UNIQUE INDEX UxAssetNoteAssetId ON AssetNote(assetId); -- one note per asset
`;