@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hearthlog/runtime",
3
- "version": "0.2.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/contracts": "0.2.0",
39
- "@hearthlog/ui": "0.2.0"
38
+ "@hearthlog/ui": "0.3.1",
39
+ "@hearthlog/contracts": "0.3.1"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "svelte": "^5.57.1",
@@ -25,7 +25,8 @@ export interface LocalCommandResult {
25
25
  projectionPending: boolean;
26
26
  }
27
27
 
28
- type CommitListener = (result: LocalCommandResult) => void | Promise<void>;
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, supplied by the enabled
7
- * modules (`AttentionContribution.badges`). Counts come from SQL against the
8
- * active log and refresh whenever events change; a failed read keeps the
9
- * previous badges because they are advisory.
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
- try {
31
- const next = (await Promise.all(this.#sources.map((source) => source.badges?.({ today }) ?? []))).flat();
32
- if (run === this.#run) this.badges = next;
33
- } catch {
34
- // Keep the last known badges; the page itself still shows the details.
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);