@hearthlog/runtime 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/access/session.ts +223 -0
- package/src/access/user-session.ts +61 -0
- package/src/ai/decisions.ts +89 -0
- package/src/calendar/draft.ts +84 -0
- package/src/calendar/feeds.ts +91 -0
- package/src/calendar/groups.ts +198 -0
- package/src/calendar/logic.ts +141 -0
- package/src/calendar/preferences.ts +40 -0
- package/src/calendar/weeks.ts +56 -0
- package/src/commands/index.ts +184 -0
- package/src/commands/local-commit.ts +214 -0
- package/src/commands/standard.ts +8 -0
- package/src/commands/validation.ts +206 -0
- package/src/db/bootstrap.ts +72 -0
- package/src/db/calendar.ts +638 -0
- package/src/db/dynamic-ddl.ts +127 -0
- package/src/db/entities.ts +208 -0
- package/src/db/history.ts +194 -0
- package/src/db/index.ts +1240 -0
- package/src/db/kv.ts +33 -0
- package/src/db/local-statistics.ts +106 -0
- package/src/db/module-projections.ts +46 -0
- package/src/db/raw-browser.ts +361 -0
- package/src/db/read-repositories.ts +29 -0
- package/src/db/read-session.ts +47 -0
- package/src/db/schema-version.ts +76 -0
- package/src/db/session-context.ts +33 -0
- package/src/event/code-export.ts +65 -0
- package/src/event/dynamic-entities.ts +503 -0
- package/src/event/dynamic-materializer.ts +288 -0
- package/src/event/handlers.ts +490 -0
- package/src/event/index.ts +53 -0
- package/src/event/meta-entity-types.ts +165 -0
- package/src/event/projection-builder.ts +384 -0
- package/src/event/queries.ts +126 -0
- package/src/event/rebuild.ts +176 -0
- package/src/event/reference-lookup.ts +93 -0
- package/src/event/registry.ts +158 -0
- package/src/event/schema-effects.ts +45 -0
- package/src/event/types.ts +20 -0
- package/src/history/action-boundary.ts +18 -0
- package/src/history/action-detail.ts +23 -0
- package/src/history/change-comparison.ts +29 -0
- package/src/history/change-display.ts +41 -0
- package/src/history/contracts.ts +63 -0
- package/src/history/history.ts +372 -0
- package/src/history/legacy-replay.ts +69 -0
- package/src/history/legacy-url.ts +9 -0
- package/src/history/lineage.ts +123 -0
- package/src/history/log-format.ts +17 -0
- package/src/history/log-map.ts +110 -0
- package/src/history/navigation.ts +14 -0
- package/src/history/point-snapshot.ts +229 -0
- package/src/history/timeline.ts +215 -0
- package/src/history/url.ts +18 -0
- package/src/ledger/README.md +118 -0
- package/src/ledger/admin-client.ts +616 -0
- package/src/ledger/client.ts +10 -0
- package/src/ledger/config.ts +117 -0
- package/src/ledger/entity-id.ts +115 -0
- package/src/ledger/errors.ts +144 -0
- package/src/ledger/http-client.ts +590 -0
- package/src/ledger/index.ts +67 -0
- package/src/ledger/socket.ts +400 -0
- package/src/ledger/types.ts +202 -0
- package/src/ledger/version-tracker-singleton.ts +12 -0
- package/src/ledger/version-tracker.ts +259 -0
- package/src/log-management.ts +123 -0
- package/src/log-validation.ts +19 -0
- package/src/native/reminders.ts +2 -0
- package/src/native/shell.svelte.ts +136 -0
- package/src/native/shell.ts +102 -0
- package/src/pwa/registration.ts +169 -0
- package/src/recovery/local-recovery.ts +59 -0
- package/src/recovery/reset-sequence.ts +13 -0
- package/src/runtime/clock.ts +43 -0
- package/src/runtime/kept-page-data.svelte.ts +42 -0
- package/src/runtime/page-data.svelte.ts +160 -0
- package/src/runtime/phone-header.svelte.ts +17 -0
- package/src/runtime/read-coordinator.ts +103 -0
- package/src/runtime/refresh.ts +15 -0
- package/src/runtime/start-page.ts +56 -0
- package/src/runtime/view.ts +83 -0
- package/src/runtime/workspace.ts +62 -0
- package/src/storage/indexeddb-statistics.ts +153 -0
- package/src/store/attention.svelte.ts +51 -0
- package/src/store/conflict-merge.ts +169 -0
- package/src/store/entity-sync-state.svelte.ts +36 -0
- package/src/store/event-log.svelte.ts +109 -0
- package/src/store/events.svelte.ts +14 -0
- package/src/store/local-context.ts +93 -0
- package/src/store/local-store.svelte.ts +58 -0
- package/src/store/log-version.svelte.ts +35 -0
- package/src/store/logs.svelte.ts +60 -0
- package/src/store/moved.svelte.ts +43 -0
- package/src/store/offline-readiness.svelte.ts +188 -0
- package/src/store/outbox-request.ts +61 -0
- package/src/store/outbox.ts +426 -0
- package/src/store/pending-work-transfer.ts +49 -0
- package/src/store/pending-work.ts +143 -0
- package/src/store/sync-coordinator.ts +170 -0
- package/src/store/sync-invalidation.ts +10 -0
- package/src/store/sync-issues.ts +406 -0
- package/src/store/sync-progress.ts +54 -0
- package/src/store/sync-state.ts +127 -0
- package/src/store/sync.svelte.ts +1010 -0
- package/src/store/tab-coordination.ts +164 -0
- package/src/testing/e2e-seed.ts +40 -0
- package/src/testing/local-database.ts +120 -0
- package/src/testing/projection-database.ts +105 -0
- package/src/testing/test-entities.ts +28 -0
- package/src/theme/appearance.svelte.ts +111 -0
- package/src/utils/address.ts +5 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/long-press.ts +70 -0
- package/src/utils/perf-dataset.ts +405 -0
- package/src/utils/perf.ts +627 -0
- package/README.md +0 -3
package/src/db/kv.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { openDB, type IDBPDatabase } from 'idb';
|
|
2
|
+
|
|
3
|
+
const DB_NAME = 'kv-store';
|
|
4
|
+
const STORE_NAME = 'key-value';
|
|
5
|
+
|
|
6
|
+
let dbPromise: Promise<IDBPDatabase<unknown>> | null = null;
|
|
7
|
+
|
|
8
|
+
function getDb(): Promise<IDBPDatabase<unknown>> {
|
|
9
|
+
if (!dbPromise) {
|
|
10
|
+
dbPromise = openDB(DB_NAME, 1, {
|
|
11
|
+
upgrade(db) {
|
|
12
|
+
db.createObjectStore(STORE_NAME);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return dbPromise;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function get<T>(key: string): Promise<T | null> {
|
|
20
|
+
const db = await getDb();
|
|
21
|
+
const value = await db.get(STORE_NAME, key);
|
|
22
|
+
return value as T | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function set(key: string, value: unknown): Promise<void> {
|
|
26
|
+
const db = await getDb();
|
|
27
|
+
await db.put(STORE_NAME, value, key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function del(key: string): Promise<void> {
|
|
31
|
+
const db = await getDb();
|
|
32
|
+
await db.delete(STORE_NAME, key);
|
|
33
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { db } from './index.js';
|
|
2
|
+
import { createKyselyRawSqliteExecutor, type RawSqliteExecutor } from './raw-browser.js';
|
|
3
|
+
|
|
4
|
+
export interface LocalSqliteStats {
|
|
5
|
+
allocatedBytes: number;
|
|
6
|
+
occupiedBytes: number;
|
|
7
|
+
reusableBytes: number;
|
|
8
|
+
pageSize: number;
|
|
9
|
+
pageCount: number;
|
|
10
|
+
freePageCount: number;
|
|
11
|
+
schemaVersion: number;
|
|
12
|
+
sqliteVersion: string;
|
|
13
|
+
tableCount: number;
|
|
14
|
+
indexCount: number;
|
|
15
|
+
activeLog: {
|
|
16
|
+
eventCount: number;
|
|
17
|
+
pendingOperationCount: number;
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const localExecutor = createKyselyRawSqliteExecutor(db);
|
|
22
|
+
|
|
23
|
+
/** File-wide page figures and SQL-scoped counts for the selected tenant/log. */
|
|
24
|
+
export async function loadLocalSqliteStats(
|
|
25
|
+
executor: RawSqliteExecutor = localExecutor,
|
|
26
|
+
): Promise<LocalSqliteStats> {
|
|
27
|
+
// SQLocal labels a bare `PRAGMA page_size` result with its value (for
|
|
28
|
+
// example "8192"), so use SQLite's table-valued read-only PRAGMAs.
|
|
29
|
+
const rows = await executor.query<{
|
|
30
|
+
page_count: number | string;
|
|
31
|
+
page_size: number | string;
|
|
32
|
+
freelist_count: number | string;
|
|
33
|
+
user_version: number | string;
|
|
34
|
+
sqlite_version: string;
|
|
35
|
+
tables: number | string;
|
|
36
|
+
indexes: number | string;
|
|
37
|
+
}>(`
|
|
38
|
+
SELECT sqlite_version() AS sqlite_version, pages.page_count, sizes.page_size, free.freelist_count, versions.user_version,
|
|
39
|
+
(SELECT COUNT(*) FROM sqlite_schema
|
|
40
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%') AS tables,
|
|
41
|
+
(SELECT COUNT(*) FROM sqlite_schema WHERE type = 'index') AS indexes
|
|
42
|
+
FROM pragma_page_count AS pages
|
|
43
|
+
CROSS JOIN pragma_page_size AS sizes
|
|
44
|
+
CROSS JOIN pragma_freelist_count AS free
|
|
45
|
+
CROSS JOIN pragma_user_version AS versions
|
|
46
|
+
`);
|
|
47
|
+
|
|
48
|
+
const pageCount = Number(rows[0]?.page_count ?? 0);
|
|
49
|
+
const pageSize = Number(rows[0]?.page_size ?? 0);
|
|
50
|
+
const freePageCount = Number(rows[0]?.freelist_count ?? 0);
|
|
51
|
+
const allocatedBytes = pageCount * pageSize;
|
|
52
|
+
const reusableBytes = freePageCount * pageSize;
|
|
53
|
+
|
|
54
|
+
let activeLog: LocalSqliteStats['activeLog'] = null;
|
|
55
|
+
try {
|
|
56
|
+
const rows = await executor.query<{
|
|
57
|
+
event_count: number;
|
|
58
|
+
pending_operation_count: number;
|
|
59
|
+
}>(`
|
|
60
|
+
SELECT
|
|
61
|
+
(SELECT COUNT(*) FROM raw_events AS events
|
|
62
|
+
WHERE events.tenant_id = context.tenant_id
|
|
63
|
+
AND events.log_id = context.log_id) AS event_count,
|
|
64
|
+
(SELECT COUNT(*) FROM outbox_operations AS operations
|
|
65
|
+
WHERE operations.tenant_id = context.tenant_id
|
|
66
|
+
AND operations.log_id = context.log_id
|
|
67
|
+
AND operations.status IN ('pending', 'syncing', 'failed', 'needs_attention')) AS pending_operation_count
|
|
68
|
+
FROM active_context AS context
|
|
69
|
+
WHERE context.id = 1
|
|
70
|
+
`);
|
|
71
|
+
if (rows[0]) {
|
|
72
|
+
activeLog = {
|
|
73
|
+
eventCount: Number(rows[0].event_count),
|
|
74
|
+
pendingOperationCount: Number(rows[0].pending_operation_count),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// The file remains inspectable before a log is selected or migration finishes.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
allocatedBytes,
|
|
83
|
+
occupiedBytes: Math.max(0, allocatedBytes - reusableBytes),
|
|
84
|
+
reusableBytes,
|
|
85
|
+
pageSize,
|
|
86
|
+
pageCount,
|
|
87
|
+
freePageCount,
|
|
88
|
+
schemaVersion: Number(rows[0]?.user_version ?? 0),
|
|
89
|
+
sqliteVersion: String(rows[0]?.sqlite_version ?? 'unknown'),
|
|
90
|
+
tableCount: Number(rows[0]?.tables ?? 0),
|
|
91
|
+
indexCount: Number(rows[0]?.indexes ?? 0),
|
|
92
|
+
activeLog,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatSqliteBytes(bytes: number): string {
|
|
97
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
98
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
99
|
+
let value = bytes;
|
|
100
|
+
let unit = -1;
|
|
101
|
+
do {
|
|
102
|
+
value /= 1024;
|
|
103
|
+
unit += 1;
|
|
104
|
+
} while (value >= 1024 && unit < units.length - 1);
|
|
105
|
+
return `${value >= 100 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The projection ledger: which version of each module's read model the local
|
|
3
|
+
* database holds. A module raises `projectionVersion` in its manifest when its
|
|
4
|
+
* tables or their meaning change; the next load rebuilds from raw events.
|
|
5
|
+
*
|
|
6
|
+
* A database that predates the ledger (schema 41) holds every module's tables
|
|
7
|
+
* at the baseline, version 1.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { MODULE_ENTITY_DEFINITIONS, MODULE_PROJECTIONS } from '#generated/modules';
|
|
11
|
+
|
|
12
|
+
export const BASELINE_PROJECTION_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
export interface ProjectionRow {
|
|
15
|
+
module_id: string;
|
|
16
|
+
projection_version: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The modules a tenant runs (at least one enabled feature) and the version each should have. */
|
|
20
|
+
export function currentProjections(features: ReadonlySet<string>): Array<{ module: string; version: number }> {
|
|
21
|
+
return MODULE_PROJECTIONS
|
|
22
|
+
.filter((projection) => projection.features.some((feature) => features.has(feature)))
|
|
23
|
+
.map(({ module, version }) => ({ module, version }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Modules whose stored projection differs from the running code; a missing row counts as the baseline. */
|
|
27
|
+
export function staleModules(
|
|
28
|
+
stored: readonly ProjectionRow[],
|
|
29
|
+
current: ReadonlyArray<{ module: string; version: number }>,
|
|
30
|
+
): string[] {
|
|
31
|
+
const versions = new Map(stored.map((row) => [row.module_id, row.projection_version]));
|
|
32
|
+
return current
|
|
33
|
+
.filter(({ module, version }) => (versions.get(module) ?? BASELINE_PROJECTION_VERSION) !== version)
|
|
34
|
+
.map(({ module }) => module);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The projection tables of the named modules: those of the entities of the features each module owns. */
|
|
38
|
+
export function moduleTables(modules: readonly string[]): string[] {
|
|
39
|
+
const features = new Set(MODULE_PROJECTIONS.filter(({ module }) => modules.includes(module)).flatMap((projection) => projection.features));
|
|
40
|
+
return MODULE_ENTITY_DEFINITIONS.filter((definition) => features.has(definition.feature)).map((definition) => definition.table);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every module's projection tables (the core's own tables are not among them). */
|
|
44
|
+
export function allModuleTables(): string[] {
|
|
45
|
+
return moduleTables(MODULE_PROJECTIONS.map(({ module }) => module));
|
|
46
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { sql, type Kysely } from 'kysely';
|
|
2
|
+
import { db, type Database } from './index.js';
|
|
3
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
4
|
+
|
|
5
|
+
export const RAW_SQLITE_PAGE_SIZES = [20, 50, 100] as const;
|
|
6
|
+
export const DEFAULT_RAW_SQLITE_PAGE_SIZE = 50;
|
|
7
|
+
|
|
8
|
+
export type RawSqlitePageSize = (typeof RAW_SQLITE_PAGE_SIZES)[number];
|
|
9
|
+
export type RawSqliteSortDirection = 'asc' | 'desc';
|
|
10
|
+
|
|
11
|
+
export interface RawSqliteExecutor {
|
|
12
|
+
query<Row extends Record<string, unknown>>(statement: string): Promise<Row[]>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RawSqliteTable {
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RawSqliteColumn {
|
|
20
|
+
name: string;
|
|
21
|
+
dataType: string;
|
|
22
|
+
notNull: boolean;
|
|
23
|
+
defaultValue: unknown;
|
|
24
|
+
primaryKeyPosition: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RawSqliteTablePage {
|
|
28
|
+
tables: RawSqliteTable[];
|
|
29
|
+
tableName: string | null;
|
|
30
|
+
columns: RawSqliteColumn[];
|
|
31
|
+
rows: Record<string, unknown>[];
|
|
32
|
+
totalRows: number;
|
|
33
|
+
currentPage: number;
|
|
34
|
+
pageSize: RawSqlitePageSize;
|
|
35
|
+
totalPages: number;
|
|
36
|
+
sortField: string | null;
|
|
37
|
+
sortDirection: RawSqliteSortDirection;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type RawSqliteValuePresentation = {
|
|
41
|
+
kind: 'null' | 'blob' | 'scalar';
|
|
42
|
+
text: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type SqliteTableRow = {
|
|
46
|
+
name: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type SqliteColumnRow = {
|
|
50
|
+
cid: number;
|
|
51
|
+
name: string;
|
|
52
|
+
type: string;
|
|
53
|
+
notnull: number;
|
|
54
|
+
dflt_value: unknown;
|
|
55
|
+
pk: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type ActiveScope = {
|
|
59
|
+
tenantId: string;
|
|
60
|
+
logId: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const localExecutor = createKyselyRawSqliteExecutor(db);
|
|
64
|
+
|
|
65
|
+
export function createKyselyRawSqliteExecutor(
|
|
66
|
+
database: Kysely<Database>,
|
|
67
|
+
): RawSqliteExecutor {
|
|
68
|
+
return {
|
|
69
|
+
async query<Row extends Record<string, unknown>>(
|
|
70
|
+
statement: string,
|
|
71
|
+
): Promise<Row[]> {
|
|
72
|
+
const result = await sql.raw<Row>(statement).execute(database);
|
|
73
|
+
return [...result.rows];
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function quoteRawSqliteIdentifier(identifier: string): string {
|
|
79
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseRawSqlitePage(value: string | number | null | undefined): number {
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseRawSqlitePageSize(
|
|
88
|
+
value: string | number | null | undefined,
|
|
89
|
+
): RawSqlitePageSize {
|
|
90
|
+
const parsed = Number(value);
|
|
91
|
+
return RAW_SQLITE_PAGE_SIZES.includes(parsed as RawSqlitePageSize)
|
|
92
|
+
? (parsed as RawSqlitePageSize)
|
|
93
|
+
: DEFAULT_RAW_SQLITE_PAGE_SIZE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseRawSqliteSortDirection(
|
|
97
|
+
value: string | number | null | undefined,
|
|
98
|
+
): RawSqliteSortDirection {
|
|
99
|
+
return String(value).toLowerCase() === 'desc' ? 'desc' : 'asc';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseRawSqliteSortField(
|
|
103
|
+
value: string | number | null | undefined,
|
|
104
|
+
columns?: ReadonlyArray<Pick<RawSqliteColumn, 'name'>> | null,
|
|
105
|
+
): string | null {
|
|
106
|
+
const trimmed = String(value ?? '').trim();
|
|
107
|
+
if (!trimmed) return null;
|
|
108
|
+
if (columns && !columns.some((column) => column.name === trimmed)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return trimmed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function listRawSqliteTables(
|
|
115
|
+
executor: RawSqliteExecutor = localExecutor,
|
|
116
|
+
allowedTableNames?: ReadonlySet<string>,
|
|
117
|
+
): Promise<RawSqliteTable[]> {
|
|
118
|
+
const rows = await executor.query<SqliteTableRow>(`
|
|
119
|
+
SELECT name
|
|
120
|
+
FROM sqlite_schema
|
|
121
|
+
WHERE type = 'table'
|
|
122
|
+
AND name NOT LIKE 'sqlite_%'
|
|
123
|
+
ORDER BY name COLLATE NOCASE, name
|
|
124
|
+
`);
|
|
125
|
+
|
|
126
|
+
const visibleTableNames = allowedTableNames
|
|
127
|
+
?? await tenantVisibleTableNames(executor);
|
|
128
|
+
return rows
|
|
129
|
+
.filter(({ name }) => visibleTableNames.has(name))
|
|
130
|
+
.map(({ name }) => ({ name }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function loadRawSqliteTablePage(
|
|
134
|
+
input: {
|
|
135
|
+
tableName?: string | null;
|
|
136
|
+
page?: string | number | null;
|
|
137
|
+
pageSize?: string | number | null;
|
|
138
|
+
sort?: string | null;
|
|
139
|
+
direction?: string | number | null;
|
|
140
|
+
},
|
|
141
|
+
executor: RawSqliteExecutor = localExecutor,
|
|
142
|
+
allowedTableNames?: ReadonlySet<string>,
|
|
143
|
+
): Promise<RawSqliteTablePage> {
|
|
144
|
+
const tables = await listRawSqliteTables(executor, allowedTableNames);
|
|
145
|
+
const selectedTable = tables.find((table) => table.name === input.tableName)
|
|
146
|
+
?? tables[0]
|
|
147
|
+
?? null;
|
|
148
|
+
const pageSize = parseRawSqlitePageSize(input.pageSize);
|
|
149
|
+
|
|
150
|
+
if (!selectedTable) {
|
|
151
|
+
return {
|
|
152
|
+
tables,
|
|
153
|
+
tableName: null,
|
|
154
|
+
columns: [],
|
|
155
|
+
rows: [],
|
|
156
|
+
totalRows: 0,
|
|
157
|
+
currentPage: 1,
|
|
158
|
+
pageSize,
|
|
159
|
+
totalPages: 1,
|
|
160
|
+
sortField: null,
|
|
161
|
+
sortDirection: 'asc',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const tablePage = await readRawSqliteTablePage(
|
|
166
|
+
{
|
|
167
|
+
tableName: selectedTable.name,
|
|
168
|
+
page: input.page,
|
|
169
|
+
pageSize,
|
|
170
|
+
sort: input.sort,
|
|
171
|
+
direction: input.direction,
|
|
172
|
+
},
|
|
173
|
+
executor,
|
|
174
|
+
tables,
|
|
175
|
+
allowedTableNames,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
tables,
|
|
180
|
+
...tablePage,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function readRawSqliteTablePage(
|
|
185
|
+
input: {
|
|
186
|
+
tableName: string;
|
|
187
|
+
page?: string | number | null;
|
|
188
|
+
pageSize?: string | number | null;
|
|
189
|
+
sort?: string | null;
|
|
190
|
+
direction?: string | number | null;
|
|
191
|
+
},
|
|
192
|
+
executor: RawSqliteExecutor = localExecutor,
|
|
193
|
+
knownTables?: RawSqliteTable[],
|
|
194
|
+
allowedTableNames?: ReadonlySet<string>,
|
|
195
|
+
): Promise<Omit<RawSqliteTablePage, 'tables'>> {
|
|
196
|
+
const visibleTableNames = allowedTableNames
|
|
197
|
+
?? await tenantVisibleTableNames(executor);
|
|
198
|
+
const tables = (knownTables ?? await listRawSqliteTables(executor, visibleTableNames))
|
|
199
|
+
.filter(({ name }) => visibleTableNames.has(name));
|
|
200
|
+
if (!tables.some((table) => table.name === input.tableName)) {
|
|
201
|
+
throw new Error(`Unknown local SQLite table "${input.tableName}"`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tableIdentifier = quoteRawSqliteIdentifier(input.tableName);
|
|
205
|
+
const columnRows = await executor.query<SqliteColumnRow>(
|
|
206
|
+
`PRAGMA table_info(${tableIdentifier})`,
|
|
207
|
+
);
|
|
208
|
+
const columns = columnRows
|
|
209
|
+
.sort((left, right) => Number(left.cid) - Number(right.cid))
|
|
210
|
+
.map((column) => ({
|
|
211
|
+
name: column.name,
|
|
212
|
+
dataType: column.type || 'ANY',
|
|
213
|
+
notNull: Boolean(column.notnull),
|
|
214
|
+
defaultValue: column.dflt_value,
|
|
215
|
+
primaryKeyPosition: Number(column.pk),
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
const scope = await activeScope(executor);
|
|
219
|
+
const isScoped = columns.some((column) => column.name === 'tenant_id')
|
|
220
|
+
&& columns.some((column) => column.name === 'log_id');
|
|
221
|
+
if (isScoped && !scope) {
|
|
222
|
+
throw new Error('No active tenant context is available for this table.');
|
|
223
|
+
}
|
|
224
|
+
const where = isScoped
|
|
225
|
+
? ` WHERE ${quoteRawSqliteIdentifier('tenant_id')} = ${quoteRawSqliteLiteral(scope!.tenantId)}\n` +
|
|
226
|
+
` AND ${quoteRawSqliteIdentifier('log_id')} = ${quoteRawSqliteLiteral(scope!.logId)}`
|
|
227
|
+
: '';
|
|
228
|
+
|
|
229
|
+
const countRows = await executor.query<{ count: number | bigint | string }>(
|
|
230
|
+
`SELECT COUNT(*) AS count FROM ${tableIdentifier}${where}`,
|
|
231
|
+
);
|
|
232
|
+
const totalRows = Number(countRows[0]?.count ?? 0);
|
|
233
|
+
const pageSize = parseRawSqlitePageSize(input.pageSize);
|
|
234
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
|
235
|
+
const currentPage = Math.min(parseRawSqlitePage(input.page), totalPages);
|
|
236
|
+
const offset = (currentPage - 1) * pageSize;
|
|
237
|
+
const sortField = parseRawSqliteSortField(input.sort, columns);
|
|
238
|
+
const sortDirection = parseRawSqliteSortDirection(input.direction);
|
|
239
|
+
const primaryKeyColumns = columns
|
|
240
|
+
.filter((column) => column.primaryKeyPosition > 0)
|
|
241
|
+
.sort((left, right) => left.primaryKeyPosition - right.primaryKeyPosition);
|
|
242
|
+
const orderByParts: string[] = [];
|
|
243
|
+
if (sortField) {
|
|
244
|
+
orderByParts.push(`${quoteRawSqliteIdentifier(sortField)} ${sortDirection.toUpperCase()}`);
|
|
245
|
+
}
|
|
246
|
+
const defaultOrderBy = primaryKeyColumns.length > 0
|
|
247
|
+
? primaryKeyColumns
|
|
248
|
+
.map((column) => `${quoteRawSqliteIdentifier(column.name)} ASC`)
|
|
249
|
+
.join(', ')
|
|
250
|
+
: '_rowid_ ASC';
|
|
251
|
+
// The sort column doubles as the tiebreak when it is unique-ish, but a
|
|
252
|
+
// stable PK/_rowid_ tiebreak keeps pagination deterministic for duplicate
|
|
253
|
+
// values (e.g. many rows sharing the same amount or status).
|
|
254
|
+
if (!sortField || !defaultOrderBy.startsWith(`${quoteRawSqliteIdentifier(sortField)} `)) {
|
|
255
|
+
orderByParts.push(defaultOrderBy);
|
|
256
|
+
}
|
|
257
|
+
const orderBy = orderByParts.join(', ');
|
|
258
|
+
const rows = await executor.query<Record<string, unknown>>(`
|
|
259
|
+
SELECT *
|
|
260
|
+
FROM ${tableIdentifier}
|
|
261
|
+
${where}
|
|
262
|
+
ORDER BY ${orderBy}
|
|
263
|
+
LIMIT ${pageSize}
|
|
264
|
+
OFFSET ${offset}
|
|
265
|
+
`);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
tableName: input.tableName,
|
|
269
|
+
columns,
|
|
270
|
+
rows,
|
|
271
|
+
totalRows,
|
|
272
|
+
currentPage,
|
|
273
|
+
pageSize,
|
|
274
|
+
totalPages,
|
|
275
|
+
sortField,
|
|
276
|
+
sortDirection,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function tenantVisibleTableNames(
|
|
281
|
+
executor: RawSqliteExecutor,
|
|
282
|
+
): Promise<ReadonlySet<string>> {
|
|
283
|
+
const visible = new Set(tenant.approvedEntities.tables);
|
|
284
|
+
if (!tenant.dynamicEntities) return visible;
|
|
285
|
+
|
|
286
|
+
const scope = await activeScope(executor);
|
|
287
|
+
if (!scope) return visible;
|
|
288
|
+
try {
|
|
289
|
+
const rows = await executor.query<{ table_name: string }>(`
|
|
290
|
+
SELECT table_name
|
|
291
|
+
FROM "entity_type_definitions"
|
|
292
|
+
WHERE tenant_id = ${quoteRawSqliteLiteral(scope.tenantId)}
|
|
293
|
+
AND log_id = ${quoteRawSqliteLiteral(scope.logId)}
|
|
294
|
+
AND is_archived = 0
|
|
295
|
+
`);
|
|
296
|
+
for (const row of rows) visible.add(row.table_name);
|
|
297
|
+
} catch {
|
|
298
|
+
// A legacy/incomplete database may not have dynamic definitions yet.
|
|
299
|
+
// Static approved tables remain available in that case.
|
|
300
|
+
}
|
|
301
|
+
return visible;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function activeScope(executor: RawSqliteExecutor): Promise<ActiveScope | null> {
|
|
305
|
+
try {
|
|
306
|
+
const rows = await executor.query<{ tenant_id: string; log_id: string }>(`
|
|
307
|
+
SELECT tenant_id, log_id
|
|
308
|
+
FROM "active_context"
|
|
309
|
+
WHERE id = 1
|
|
310
|
+
LIMIT 1
|
|
311
|
+
`);
|
|
312
|
+
const row = rows[0];
|
|
313
|
+
if (!row?.tenant_id || !row.log_id) return null;
|
|
314
|
+
return { tenantId: row.tenant_id, logId: row.log_id };
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function quoteRawSqliteLiteral(value: string): string {
|
|
321
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function presentRawSqliteValue(value: unknown): RawSqliteValuePresentation {
|
|
325
|
+
if (value === null || value === undefined) {
|
|
326
|
+
return { kind: 'null', text: 'NULL' };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (value instanceof ArrayBuffer) {
|
|
330
|
+
return { kind: 'blob', text: formatBlobSize(value.byteLength) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (ArrayBuffer.isView(value)) {
|
|
334
|
+
return { kind: 'blob', text: formatBlobSize(value.byteLength) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (typeof value === 'string') {
|
|
338
|
+
return { kind: 'scalar', text: value };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
typeof value === 'number'
|
|
343
|
+
|| typeof value === 'bigint'
|
|
344
|
+
|| typeof value === 'boolean'
|
|
345
|
+
) {
|
|
346
|
+
return { kind: 'scalar', text: String(value) };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
return {
|
|
351
|
+
kind: 'scalar',
|
|
352
|
+
text: JSON.stringify(value) ?? String(value),
|
|
353
|
+
};
|
|
354
|
+
} catch {
|
|
355
|
+
return { kind: 'scalar', text: String(value) };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function formatBlobSize(byteLength: number): string {
|
|
360
|
+
return `<BLOB ${byteLength} ${byteLength === 1 ? 'byte' : 'bytes'}>`;
|
|
361
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createStandardRepositories } from '#generated/standard-db';
|
|
2
|
+
import type { ReadModelSession } from './read-session.js';
|
|
3
|
+
import { runActiveRead } from '../runtime/read-coordinator.js';
|
|
4
|
+
import { createCalendarRepository } from './calendar.js';
|
|
5
|
+
import { createEntityRepository } from './entities.js';
|
|
6
|
+
import { createHistoryRepository } from './history.js';
|
|
7
|
+
import { createModuleRepositories } from '#generated/module-repositories';
|
|
8
|
+
|
|
9
|
+
function createReadRepositories(session: ReadModelSession) {
|
|
10
|
+
return {
|
|
11
|
+
records: createStandardRepositories(session),
|
|
12
|
+
...createModuleRepositories(session),
|
|
13
|
+
calendar: createCalendarRepository(session),
|
|
14
|
+
history: createHistoryRepository(session),
|
|
15
|
+
entities: createEntityRepository(session),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ReadRepositories = ReturnType<typeof createReadRepositories>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Executes ordinary application reads against the currently active database view.
|
|
23
|
+
* Schema selection, snapshot leasing, and tenant/log scoping stay inside the data layer.
|
|
24
|
+
*/
|
|
25
|
+
export function queryDatabase<T>(
|
|
26
|
+
callback: (repositories: ReadRepositories) => Promise<T>,
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
return runActiveRead((session) => callback(createReadRepositories(session)));
|
|
29
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { sql, type Kysely, type QueryResult, type RawBuilder, type SelectQueryBuilder, type SqlBool } from 'kysely';
|
|
2
|
+
import type { EntityTypeDefinitionTable } from './index.js';
|
|
3
|
+
import type { EntityDatabaseTables } from '#generated/schema';
|
|
4
|
+
import { qualifiedIdentifier } from '../event/projection-builder.js';
|
|
5
|
+
|
|
6
|
+
export type ReadScope = { tenantId: string; logId: string };
|
|
7
|
+
export type ReadView = { effectiveNow: string };
|
|
8
|
+
|
|
9
|
+
export interface ReadModelDatabase extends EntityDatabaseTables {
|
|
10
|
+
entity_type_definitions: EntityTypeDefinitionTable;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read-model queries are always scoped to the selected tenant and log. */
|
|
14
|
+
export interface ReadModelSession {
|
|
15
|
+
readonly scope: ReadScope;
|
|
16
|
+
readonly view: ReadView;
|
|
17
|
+
selectFrom<TB extends keyof ReadModelDatabase>(table: TB): SelectQueryBuilder<ReadModelDatabase, TB, {}>;
|
|
18
|
+
table(name: string): RawBuilder<unknown>;
|
|
19
|
+
execute<T>(query: RawBuilder<T>): Promise<QueryResult<T>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createReadModelSession(
|
|
23
|
+
database: Kysely<ReadModelDatabase>,
|
|
24
|
+
options: { schema: string; scope: ReadScope; view: ReadView },
|
|
25
|
+
): ReadModelSession {
|
|
26
|
+
const scopedDb = database.withSchema(options.schema);
|
|
27
|
+
const { scope } = options;
|
|
28
|
+
|
|
29
|
+
function scopedSelect<TB extends keyof ReadModelDatabase>(table: TB): SelectQueryBuilder<ReadModelDatabase, TB, {}> {
|
|
30
|
+
const scoped = scopedDb.selectFrom(table) as SelectQueryBuilder<ReadModelDatabase, TB, {}>;
|
|
31
|
+
return scoped
|
|
32
|
+
.where(sql<SqlBool>`${sql.ref(`${table}.tenant_id`)} = ${scope.tenantId}`)
|
|
33
|
+
.where(sql<SqlBool>`${sql.ref(`${table}.log_id`)} = ${scope.logId}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
scope,
|
|
38
|
+
view: options.view,
|
|
39
|
+
selectFrom: scopedSelect,
|
|
40
|
+
table(name) {
|
|
41
|
+
return sql`(SELECT * FROM ${sql.raw(qualifiedIdentifier(options.schema, name))} WHERE tenant_id = ${scope.tenantId} AND log_id = ${scope.logId})`;
|
|
42
|
+
},
|
|
43
|
+
execute(query) {
|
|
44
|
+
return query.execute(database);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|