1240 lines
94 KiB
JavaScript
1240 lines
94 KiB
JavaScript
// 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
|
|
`;
|