@hearthlog/runtime 0.2.0 → 0.3.1
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/package.json +3 -3
- package/src/commands/index.ts +5 -4
- package/src/commands/undo.ts +32 -0
- package/src/store/attention.svelte.ts +31 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hearthlog/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Hearthlog's offline runtime: the local database, events and projections, commands, the outbox and sync, the Ledger client, history, and page lifecycle",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"date-fns": "^4.4.0",
|
|
36
36
|
"sqlocal": "^0.18.0",
|
|
37
37
|
"idb": "^8.0.3",
|
|
38
|
-
"@hearthlog/
|
|
39
|
-
"@hearthlog/
|
|
38
|
+
"@hearthlog/ui": "0.3.1",
|
|
39
|
+
"@hearthlog/contracts": "0.3.1"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"svelte": "^5.57.1",
|
package/src/commands/index.ts
CHANGED
|
@@ -25,7 +25,8 @@ export interface LocalCommandResult {
|
|
|
25
25
|
projectionPending: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/** Runs after a new batch commits on this device, with the events it appended (never for a duplicate submission). */
|
|
29
|
+
type CommitListener = (result: LocalCommandResult, events: readonly ExtendedAppEvent[]) => void | Promise<void>;
|
|
29
30
|
|
|
30
31
|
const commitListeners = new Set<CommitListener>();
|
|
31
32
|
const inFlightCommands = new Map<string, { content: string; execution: Promise<LocalCommandResult> }>();
|
|
@@ -113,7 +114,7 @@ async function execute(
|
|
|
113
114
|
projectionPending,
|
|
114
115
|
};
|
|
115
116
|
|
|
116
|
-
if (!committed.duplicate) await notifyCommitted(result);
|
|
117
|
+
if (!committed.duplicate) await notifyCommitted(result, events);
|
|
117
118
|
return result;
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -173,10 +174,10 @@ export async function retryPendingCommandProjections(): Promise<number> {
|
|
|
173
174
|
return completed;
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
async function notifyCommitted(result: LocalCommandResult): Promise<void> {
|
|
177
|
+
async function notifyCommitted(result: LocalCommandResult, events: readonly ExtendedAppEvent[]): Promise<void> {
|
|
177
178
|
for (const listener of commitListeners) {
|
|
178
179
|
try {
|
|
179
|
-
await listener(result);
|
|
180
|
+
await listener(result, events);
|
|
180
181
|
} catch (error) {
|
|
181
182
|
console.error('[Command] Post-commit listener failed', error);
|
|
182
183
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ENTITY_DEFINITIONS, getEventPrefix } from '@hearthlog/contracts/event/entity-definitions';
|
|
2
|
+
import type { ExtendedAppEvent } from '../event/types.js';
|
|
3
|
+
|
|
4
|
+
/** A record whose removal reverses its addition. */
|
|
5
|
+
export interface UndoTarget {
|
|
6
|
+
type: string;
|
|
7
|
+
id: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Standard entities: each adds, updates and removes through `<PREFIX>ADDED|UPDATED|REMOVED`. */
|
|
11
|
+
const STANDARD_PREFIXES = new Set(ENTITY_DEFINITIONS.map(getEventPrefix));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Undo appends the opposite of an action; history is never edited and no event is tombstoned. Only an action that
|
|
15
|
+
* just added standard records has an exact opposite, their removal, so anything else (an edit, a removal, a
|
|
16
|
+
* module's own event) returns null and offers no undo.
|
|
17
|
+
*/
|
|
18
|
+
export function undoTargets(events: readonly ExtendedAppEvent[]): UndoTarget[] | null {
|
|
19
|
+
const targets: UndoTarget[] = [];
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
const prefix = event.type.endsWith('_ADDED') ? event.type.slice(0, -'ADDED'.length) : null;
|
|
22
|
+
const id = (event.payload as { id?: unknown } | null)?.id;
|
|
23
|
+
if (!prefix || !STANDARD_PREFIXES.has(prefix) || typeof id !== 'string' || id === '') return null;
|
|
24
|
+
targets.push({ type: `${prefix}REMOVED`, id });
|
|
25
|
+
}
|
|
26
|
+
return targets.length > 0 ? targets : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The removal events for an undo, stamped when it runs. */
|
|
30
|
+
export function undoEvents(targets: readonly UndoTarget[], now = Date.now()): ExtendedAppEvent[] {
|
|
31
|
+
return targets.map(({ type, id }) => ({ id: crypto.randomUUID(), type, timestamp: now, payload: { id } }) as ExtendedAppEvent);
|
|
32
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { format } from 'date-fns';
|
|
2
|
-
import type { AttentionBadge, AttentionContribution } from '@hearthlog/contracts';
|
|
2
|
+
import type { AttentionBadge, AttentionContribution, NavFigure } from '@hearthlog/contracts';
|
|
3
3
|
import { appClock } from '../runtime/view.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Navigation badges: counts of work that is late
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Navigation badges and figures: counts of work that is late
|
|
7
|
+
* (`AttentionContribution.badges`) and one live figure per place
|
|
8
|
+
* (`AttentionContribution.figures`), supplied by the enabled modules. Both come
|
|
9
|
+
* from SQL against the active log and refresh whenever events change; a failed
|
|
10
|
+
* read keeps the previous values because they are advisory.
|
|
10
11
|
*/
|
|
11
12
|
class AttentionStore {
|
|
12
13
|
badges = $state<AttentionBadge[]>([]);
|
|
14
|
+
figures = $state<NavFigure[]>([]);
|
|
13
15
|
#sources: readonly AttentionContribution[] = [];
|
|
14
16
|
#run = 0;
|
|
15
17
|
|
|
@@ -27,12 +29,25 @@ class AttentionStore {
|
|
|
27
29
|
async refresh(): Promise<void> {
|
|
28
30
|
const run = ++this.#run;
|
|
29
31
|
const today = format(appClock.now(), 'yyyy-MM-dd');
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// Read apart, so a failing figure never hides late work and the reverse.
|
|
33
|
+
await Promise.all([
|
|
34
|
+
(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const next = (await Promise.all(this.#sources.map((source) => source.badges?.({ today }) ?? []))).flat();
|
|
37
|
+
if (run === this.#run) this.badges = next;
|
|
38
|
+
} catch {
|
|
39
|
+
// Keep the last known badges; the page itself still shows the details.
|
|
40
|
+
}
|
|
41
|
+
})(),
|
|
42
|
+
(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const next = (await Promise.all(this.#sources.map((source) => source.figures?.({ today }) ?? []))).flat();
|
|
45
|
+
if (run === this.#run) this.figures = next;
|
|
46
|
+
} catch {
|
|
47
|
+
// Keep the last known figures.
|
|
48
|
+
}
|
|
49
|
+
})(),
|
|
50
|
+
]);
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/** Badge for a navigation destination or sub-page id, or null when nothing is waiting. */
|
|
@@ -40,6 +55,11 @@ class AttentionStore {
|
|
|
40
55
|
return this.badges.find((badge) => badge.count > 0 && badge.ids.includes(id)) ?? null;
|
|
41
56
|
}
|
|
42
57
|
|
|
58
|
+
/** Live figure for a navigation destination or sub-page id, or null when the module shows none. */
|
|
59
|
+
figure(id: string): NavFigure | null {
|
|
60
|
+
return this.figures.find((figure) => figure.id === id) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** Total of all badges: the native app icon's badge. */
|
|
44
64
|
get total(): number {
|
|
45
65
|
return this.badges.reduce((sum, badge) => sum + badge.count, 0);
|