@mulmoclaude/accounting-plugin 2.1.0 → 3.0.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.
Files changed (50) hide show
  1. package/README.md +37 -0
  2. package/dist/server/context.d.ts +45 -3
  3. package/dist/server/context.d.ts.map +1 -1
  4. package/dist/server/eventPublisher.d.ts +9 -4
  5. package/dist/server/eventPublisher.d.ts.map +1 -1
  6. package/dist/server/index.d.ts +1 -0
  7. package/dist/server/index.d.ts.map +1 -1
  8. package/dist/server/router.d.ts +27 -3
  9. package/dist/server/router.d.ts.map +1 -1
  10. package/dist/server/snapshotCache.d.ts +3 -3
  11. package/dist/server/snapshotCache.d.ts.map +1 -1
  12. package/dist/server.cjs +149 -80
  13. package/dist/server.cjs.map +1 -1
  14. package/dist/server.js +149 -80
  15. package/dist/server.js.map +1 -1
  16. package/dist/shared/api.d.ts +12 -0
  17. package/dist/shared/api.d.ts.map +1 -1
  18. package/dist/shared/channels.d.ts +22 -2
  19. package/dist/shared/channels.d.ts.map +1 -1
  20. package/dist/{shared-BZAAF-I4.js → shared-BccLo0Ux.js} +39 -5
  21. package/dist/shared-BccLo0Ux.js.map +1 -0
  22. package/dist/{shared-BpFVwa6Y.cjs → shared-Dgt-i23T.cjs} +50 -4
  23. package/dist/shared-Dgt-i23T.cjs.map +1 -0
  24. package/dist/shared.cjs +3 -1
  25. package/dist/shared.js +2 -2
  26. package/dist/style.css +19 -10
  27. package/dist/vue/View.vue.d.ts +5 -0
  28. package/dist/vue/View.vue.d.ts.map +1 -1
  29. package/dist/vue/api.d.ts +132 -100
  30. package/dist/vue/api.d.ts.map +1 -1
  31. package/dist/vue/components/AccountsModal.vue.d.ts.map +1 -1
  32. package/dist/vue/components/BalanceSheet.vue.d.ts.map +1 -1
  33. package/dist/vue/components/BookSettings.vue.d.ts.map +1 -1
  34. package/dist/vue/components/JournalEntryForm.vue.d.ts.map +1 -1
  35. package/dist/vue/components/JournalList.vue.d.ts.map +1 -1
  36. package/dist/vue/components/Ledger.vue.d.ts.map +1 -1
  37. package/dist/vue/components/NewBookForm.vue.d.ts.map +1 -1
  38. package/dist/vue/components/OpeningBalancesForm.vue.d.ts.map +1 -1
  39. package/dist/vue/components/ProfitLoss.vue.d.ts.map +1 -1
  40. package/dist/vue/hostContext.d.ts +15 -0
  41. package/dist/vue/hostContext.d.ts.map +1 -1
  42. package/dist/vue/useAccountingChannel.d.ts +13 -3
  43. package/dist/vue/useAccountingChannel.d.ts.map +1 -1
  44. package/dist/vue.cjs +227 -98
  45. package/dist/vue.cjs.map +1 -1
  46. package/dist/vue.js +228 -99
  47. package/dist/vue.js.map +1 -1
  48. package/package.json +4 -3
  49. package/dist/shared-BZAAF-I4.js.map +0 -1
  50. package/dist/shared-BpFVwa6Y.cjs.map +0 -1
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as resolveFiscalYearEnd, B as BALANCE_SHEET_ACCOUNT_TYPES, D as isFiscalYearEnd, F as isUnknownArray, H as ACCOUNTING_API, I as ACCOUNTING_BOOKS_CHANNEL, L as BOOK_EVENT_KINDS, M as errorMessage, N as hasStringProp, P as isRecord, R as bookChannel, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, U as ACCOUNTING_ACTIONS, V as JOURNAL_ENTRY_KINDS, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as ACCOUNT_TYPES } from "./shared-BZAAF-I4.js";
1
+ import { A as resolveFiscalYearEnd, B as ACCOUNT_TYPES, D as isFiscalYearEnd, F as isUnknownArray, G as ACCOUNTING_ACTIONS, H as JOURNAL_ENTRY_KINDS, L as BOOK_EVENT_KINDS, M as errorMessage, N as hasStringProp, P as isRecord, R as bookChannel, S as FISCAL_YEAR_END_MONTHS, T as fiscalYearEndMonth, U as ACCOUNTING_API, V as BALANCE_SHEET_ACCOUNT_TYPES, _ as SUPPORTED_COUNTRY_CODES, j as ACCOUNTING_DIRS, n as TIME_SERIES_METRICS, t as TIME_SERIES_GRANULARITIES, y as isSupportedCountryCode, z as booksChannel } from "./shared-BccLo0Ux.js";
2
2
  import { Router } from "express";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
@@ -60,11 +60,41 @@ function configureAccountingServer(context) {
60
60
  }
61
61
  /** Default workspace root for io calls that don't pass one explicitly.
62
62
  * Throws if the host never configured the server — a real wiring bug
63
- * (unit tests always pass an explicit root, so they never hit this). */
63
+ * (unit tests always pass an explicit root, so they never hit this)
64
+ * and also under STRICT mode (`workspaceRoot: null`), where there is no
65
+ * ambient root to fall back to and a missing option is a bug rather
66
+ * than a default. */
64
67
  function defaultWorkspaceRoot() {
65
68
  if (!deps) throw new Error("@mulmoclaude/accounting-plugin: configureAccountingServer() must be called before serving accounting requests");
69
+ if (deps.workspaceRoot === null) throw new Error("@mulmoclaude/accounting-plugin: this host is configured with workspaceRoot: null (explicit-root mode), so every call must pass a workspaceRoot. A call reached the engine without one.");
66
70
  return deps.workspaceRoot;
67
71
  }
72
+ /** The opaque channel scope for a root, or `null` when the host declared
73
+ * none (single-root hosts, and a multi-root host's default root). Used
74
+ * by the event publisher; never contains a path.
75
+ *
76
+ * A missing root throws under explicit-root mode, exactly as
77
+ * `defaultWorkspaceRoot()` does. Returning `null` there would be worse
78
+ * than the io-path bug it mirrors: the event would go out on the
79
+ * UNSCOPED channel name, which every project's default-scope
80
+ * subscriber receives. The publisher catches it and logs, so a dropped
81
+ * root costs one event and a warning rather than a silent cross-project
82
+ * notification. */
83
+ function channelScopeFor(workspaceRoot) {
84
+ if (!deps?.channelScopeForRoot) return null;
85
+ return deps.channelScopeForRoot(workspaceRoot ?? defaultWorkspaceRoot());
86
+ }
87
+ /** Whether the host declared project scoping at all.
88
+ *
89
+ * The difference matters where a card records the project it belongs
90
+ * to: on a scoped host, "no scope" is a REAL answer (the default root)
91
+ * and must be recorded as `null`, not left out — an omitted field means
92
+ * "ask the host what is active now", which is precisely the drift a
93
+ * pinned card exists to prevent. On an unscoped host the field is left
94
+ * out entirely and every payload stays byte-identical to today's. */
95
+ function isProjectScopedHost() {
96
+ return Boolean(deps?.channelScopeForRoot);
97
+ }
68
98
  var consoleLogger = {
69
99
  error: (namespace, msg, data) => console.error(`[${namespace}] ${msg}`, data ?? ""),
70
100
  warn: (namespace, msg, data) => console.warn(`[${namespace}] ${msg}`, data ?? ""),
@@ -1141,9 +1171,16 @@ var pubsub = null;
1141
1171
  function initAccountingEventPublisher(instance) {
1142
1172
  pubsub = instance;
1143
1173
  }
1144
- function safePublish(channel, payload) {
1174
+ /** `channelFor` is called INSIDE the guard, not before it: resolving the
1175
+ * scope can throw (a write that reached the publisher with no root
1176
+ * under explicit-root mode), and a lost event with a warning beats
1177
+ * either crashing the write or publishing to the unscoped channel that
1178
+ * every project can hear. */
1179
+ function safePublish(channelFor, payload) {
1145
1180
  if (!pubsub) return;
1181
+ let channel = "(unresolved)";
1146
1182
  try {
1183
+ channel = channelFor();
1147
1184
  pubsub.publish(channel, payload);
1148
1185
  } catch (err) {
1149
1186
  log.warn("accounting", "publish failed; subscribers will miss this event", {
@@ -1154,15 +1191,20 @@ function safePublish(channel, payload) {
1154
1191
  }
1155
1192
  /** Per-book change notification. `period` should be the entry's
1156
1193
  * YYYY-MM bucket (or the earliest invalidated month for snapshot
1157
- * events). */
1158
- function publishBookChange(bookId, payload) {
1159
- safePublish(bookChannel(bookId), payload);
1194
+ * events).
1195
+ *
1196
+ * `workspaceRoot` is the root the write happened under; the channel
1197
+ * name is namespaced by the host's opaque scope for it (see
1198
+ * `channelScopeFor`). Omit it — as a single-root host's service calls
1199
+ * do — and the name is what it has always been. */
1200
+ function publishBookChange(bookId, payload, workspaceRoot) {
1201
+ safePublish(() => bookChannel(bookId, channelScopeFor(workspaceRoot)), payload);
1160
1202
  }
1161
1203
  /** Fired when the *list* of books changes (createBook, deleteBook).
1162
1204
  * Payload is intentionally empty — subscribers refetch from
1163
- * /api/accounting. */
1164
- function publishBooksChanged() {
1165
- safePublish(ACCOUNTING_BOOKS_CHANNEL, {});
1205
+ * /api/accounting. Scoped by root like `publishBookChange`. */
1206
+ function publishBooksChanged(workspaceRoot) {
1207
+ safePublish(() => booksChannel(channelScopeFor(workspaceRoot)), {});
1166
1208
  }
1167
1209
  //#endregion
1168
1210
  //#region src/server/snapshotCache.ts
@@ -1231,18 +1273,39 @@ async function rebuildAllSnapshots(bookId, workspaceRoot) {
1231
1273
  return { rebuilt: periods };
1232
1274
  }
1233
1275
  var rebuildQueues = /* @__PURE__ */ new Map();
1276
+ /** Canonical key for the root a rebuild belongs to. An absent root
1277
+ * resolves to the host's configured one, so a call that passes it
1278
+ * explicitly and one that relies on the default share a queue. Under
1279
+ * explicit-root mode there is no default and `defaultWorkspaceRoot()`
1280
+ * throws — deliberately loud: a `cancelRebuild` / `awaitRebuildIdle`
1281
+ * that quietly keyed a queue nobody scheduled would return while the
1282
+ * real rebuild is still writing, which is how `deleteBook` races a
1283
+ * `writeSnapshot` back into existence.
1284
+ *
1285
+ * Lexical resolution only — symlinks are deliberately NOT resolved,
1286
+ * matching `canonicalRoot` in `@mulmoclaude/core`'s collection engine,
1287
+ * so one project has one identity across both packages. A host that
1288
+ * hands the engine a real path in one call and a symlink alias in the
1289
+ * next would split the queue; the fix belongs in the host's own root
1290
+ * resolution, which is where the two packages agree it lives. */
1291
+ function rootKey(workspaceRoot) {
1292
+ return path.resolve(workspaceRoot ?? defaultWorkspaceRoot());
1293
+ }
1294
+ function queueKey(bookId, workspaceRoot) {
1295
+ return `${rootKey(workspaceRoot)}\u0000${bookId}`;
1296
+ }
1234
1297
  function minPeriod(lhs, rhs) {
1235
1298
  if (lhs === null) return rhs;
1236
1299
  return lhs < rhs ? lhs : rhs;
1237
1300
  }
1238
- function isInvalidatedDuringRebuild(bookId, period) {
1239
- const queue = rebuildQueues.get(bookId);
1301
+ function isInvalidatedDuringRebuild(key, period) {
1302
+ const queue = rebuildQueues.get(key);
1240
1303
  return queue !== void 0 && queue.pendingFromPeriod !== null && period >= queue.pendingFromPeriod;
1241
1304
  }
1242
- function isCancelled(bookId) {
1243
- return rebuildQueues.get(bookId)?.cancelled === true;
1305
+ function isCancelled(key) {
1306
+ return rebuildQueues.get(key)?.cancelled === true;
1244
1307
  }
1245
- async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1308
+ async function runRebuild(key, bookId, fromPeriod, workspaceRoot) {
1246
1309
  const startedAt = Date.now();
1247
1310
  log.info("accounting", "snapshot rebuild started", {
1248
1311
  bookId,
@@ -1251,25 +1314,25 @@ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1251
1314
  publishBookChange(bookId, {
1252
1315
  kind: BOOK_EVENT_KINDS.snapshotsRebuilding,
1253
1316
  period: fromPeriod
1254
- });
1317
+ }, workspaceRoot);
1255
1318
  const targets = (await listJournalPeriods(bookId, workspaceRoot)).filter((monthKey) => monthKey >= fromPeriod);
1256
1319
  let written = 0;
1257
1320
  for (const monthKey of targets) {
1258
- if (isCancelled(bookId)) break;
1259
- if (isInvalidatedDuringRebuild(bookId, monthKey)) break;
1321
+ if (isCancelled(key)) break;
1322
+ if (isInvalidatedDuringRebuild(key, monthKey)) break;
1260
1323
  const balances = await balancesAtEndOf(bookId, monthKey, workspaceRoot);
1261
- if (isCancelled(bookId)) break;
1262
- if (isInvalidatedDuringRebuild(bookId, monthKey)) break;
1324
+ if (isCancelled(key)) break;
1325
+ if (isInvalidatedDuringRebuild(key, monthKey)) break;
1263
1326
  await writeSnapshot(bookId, {
1264
1327
  period: monthKey,
1265
1328
  balances,
1266
1329
  builtAt: (/* @__PURE__ */ new Date()).toISOString()
1267
1330
  }, workspaceRoot);
1268
- if (isCancelled(bookId)) {
1331
+ if (isCancelled(key)) {
1269
1332
  await invalidateSnapshotsFrom(bookId, monthKey, workspaceRoot);
1270
1333
  break;
1271
1334
  }
1272
- if (isInvalidatedDuringRebuild(bookId, monthKey)) {
1335
+ if (isInvalidatedDuringRebuild(key, monthKey)) {
1273
1336
  await invalidateSnapshotsFrom(bookId, monthKey, workspaceRoot);
1274
1337
  break;
1275
1338
  }
@@ -1277,7 +1340,7 @@ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1277
1340
  publishBookChange(bookId, {
1278
1341
  kind: BOOK_EVENT_KINDS.snapshotsReady,
1279
1342
  period: monthKey
1280
- });
1343
+ }, workspaceRoot);
1281
1344
  }
1282
1345
  log.info("accounting", "snapshot rebuild done", {
1283
1346
  bookId,
@@ -1285,7 +1348,7 @@ async function runRebuild(bookId, fromPeriod, workspaceRoot) {
1285
1348
  durationMs: Date.now() - startedAt
1286
1349
  });
1287
1350
  }
1288
- function startRebuild(bookId, fromPeriod, workspaceRoot) {
1351
+ function startRebuild(key, bookId, fromPeriod, workspaceRoot) {
1289
1352
  const entry = {
1290
1353
  running: Promise.resolve(),
1291
1354
  pendingFromPeriod: null,
@@ -1294,27 +1357,27 @@ function startRebuild(bookId, fromPeriod, workspaceRoot) {
1294
1357
  runningFromPeriod: fromPeriod,
1295
1358
  cancelled: false
1296
1359
  };
1297
- entry.running = runRebuild(bookId, fromPeriod, workspaceRoot).catch((err) => {
1360
+ entry.running = runRebuild(key, bookId, fromPeriod, workspaceRoot).catch((err) => {
1298
1361
  log.error("accounting", "snapshot rebuild failed", {
1299
1362
  bookId,
1300
1363
  fromPeriod,
1301
1364
  error: errorMessage(err)
1302
1365
  });
1303
1366
  }).then(() => {
1304
- const current = rebuildQueues.get(bookId);
1367
+ const current = rebuildQueues.get(key);
1305
1368
  if (!current) return;
1306
1369
  if (current.cancelled) {
1307
- rebuildQueues.delete(bookId);
1370
+ rebuildQueues.delete(key);
1308
1371
  return;
1309
1372
  }
1310
1373
  if (current.pendingFromPeriod !== null) {
1311
1374
  const nextFrom = current.pendingFromPeriod;
1312
1375
  const nextRoot = current.pendingWorkspaceRoot;
1313
1376
  const carriedCount = current.coalescedWriteCount;
1314
- const successor = startRebuild(bookId, nextFrom, nextRoot);
1377
+ const successor = startRebuild(key, bookId, nextFrom, nextRoot);
1315
1378
  successor.coalescedWriteCount += carriedCount;
1316
- rebuildQueues.set(bookId, successor);
1317
- } else rebuildQueues.delete(bookId);
1379
+ rebuildQueues.set(key, successor);
1380
+ } else rebuildQueues.delete(key);
1318
1381
  });
1319
1382
  return entry;
1320
1383
  }
@@ -1323,9 +1386,10 @@ function startRebuild(bookId, fromPeriod, workspaceRoot) {
1323
1386
  * follow-up rebuild that covers the minimum `fromPeriod` seen.
1324
1387
  * Returns immediately — the rebuild runs on its own promise chain. */
1325
1388
  function scheduleRebuild(bookId, fromPeriod, workspaceRoot) {
1326
- const existing = rebuildQueues.get(bookId);
1389
+ const key = queueKey(bookId, workspaceRoot);
1390
+ const existing = rebuildQueues.get(key);
1327
1391
  if (!existing) {
1328
- rebuildQueues.set(bookId, startRebuild(bookId, fromPeriod, workspaceRoot));
1392
+ rebuildQueues.set(key, startRebuild(key, bookId, fromPeriod, workspaceRoot));
1329
1393
  return;
1330
1394
  }
1331
1395
  existing.pendingFromPeriod = minPeriod(existing.pendingFromPeriod, fromPeriod);
@@ -1336,9 +1400,10 @@ function scheduleRebuild(bookId, fromPeriod, workspaceRoot) {
1336
1400
  * `bookId`. Also called by `deleteBook` after `cancelRebuild` to
1337
1401
  * ensure a previously running rebuild has fully stopped before the
1338
1402
  * caller removes the book's directory on disk. */
1339
- async function awaitRebuildIdle(bookId) {
1340
- while (rebuildQueues.has(bookId)) {
1341
- const entry = rebuildQueues.get(bookId);
1403
+ async function awaitRebuildIdle(bookId, workspaceRoot) {
1404
+ const key = queueKey(bookId, workspaceRoot);
1405
+ while (rebuildQueues.has(key)) {
1406
+ const entry = rebuildQueues.get(key);
1342
1407
  if (!entry) return;
1343
1408
  await entry.running;
1344
1409
  }
@@ -1348,8 +1413,8 @@ async function awaitRebuildIdle(bookId) {
1348
1413
  * `removeBookDir` cannot race with a `writeSnapshot` that would
1349
1414
  * re-create the directory tree. Pair with `awaitRebuildIdle(bookId)`
1350
1415
  * to wait for the in-flight rebuild to finish bailing. */
1351
- function cancelRebuild(bookId) {
1352
- const entry = rebuildQueues.get(bookId);
1416
+ function cancelRebuild(bookId, workspaceRoot) {
1417
+ const entry = rebuildQueues.get(queueKey(bookId, workspaceRoot));
1353
1418
  if (!entry) return;
1354
1419
  entry.cancelled = true;
1355
1420
  entry.pendingFromPeriod = null;
@@ -1703,7 +1768,7 @@ async function createBook(input, workspaceRoot) {
1703
1768
  await ensureBookDir(bookId, workspaceRoot);
1704
1769
  await writeAccounts(bookId, [...DEFAULT_ACCOUNTS], workspaceRoot);
1705
1770
  await writeConfig({ books: [...config.books, book] }, workspaceRoot);
1706
- publishBooksChanged();
1771
+ publishBooksChanged(workspaceRoot);
1707
1772
  return { book };
1708
1773
  }
1709
1774
  async function updateBook(input, workspaceRoot) {
@@ -1720,7 +1785,7 @@ async function updateBook(input, workspaceRoot) {
1720
1785
  };
1721
1786
  if (country === "") delete next.country;
1722
1787
  await writeConfig({ books: config.books.map((book) => book.id === input.bookId ? next : book) }, workspaceRoot);
1723
- publishBooksChanged();
1788
+ publishBooksChanged(workspaceRoot);
1724
1789
  return { book: next };
1725
1790
  }
1726
1791
  async function deleteBook(input, workspaceRoot) {
@@ -1728,11 +1793,11 @@ async function deleteBook(input, workspaceRoot) {
1728
1793
  const config = await loadOrInitConfig(workspaceRoot);
1729
1794
  const target = findBook(config, input.bookId);
1730
1795
  if (!target) throw new AccountingError(404, `book ${JSON.stringify(input.bookId)} not found`);
1731
- cancelRebuild(input.bookId);
1732
- await awaitRebuildIdle(input.bookId);
1796
+ cancelRebuild(input.bookId, workspaceRoot);
1797
+ await awaitRebuildIdle(input.bookId, workspaceRoot);
1733
1798
  await removeBookDir(input.bookId, workspaceRoot);
1734
1799
  await writeConfig({ books: config.books.filter((book) => book.id !== input.bookId) }, workspaceRoot);
1735
- publishBooksChanged();
1800
+ publishBooksChanged(workspaceRoot);
1736
1801
  return {
1737
1802
  deletedBookId: input.bookId,
1738
1803
  deletedBookName: target.name
@@ -1782,7 +1847,7 @@ async function upsertAccount(input, workspaceRoot) {
1782
1847
  scheduleRebuild(bookId, "0000-00", workspaceRoot);
1783
1848
  await invalidateAllSnapshots(bookId, workspaceRoot);
1784
1849
  }
1785
- publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.accounts });
1850
+ publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.accounts }, workspaceRoot);
1786
1851
  return {
1787
1852
  bookId,
1788
1853
  account,
@@ -1831,7 +1896,7 @@ async function addEntries(input, workspaceRoot) {
1831
1896
  publishBookChange(bookId, {
1832
1897
  kind: BOOK_EVENT_KINDS.journal,
1833
1898
  period: earliestPeriod
1834
- });
1899
+ }, workspaceRoot);
1835
1900
  return {
1836
1901
  bookId,
1837
1902
  entries: built
@@ -1860,7 +1925,7 @@ async function voidEntry(input, workspaceRoot) {
1860
1925
  publishBookChange(bookId, {
1861
1926
  kind: BOOK_EVENT_KINDS.journal,
1862
1927
  period: fromPeriod
1863
- });
1928
+ }, workspaceRoot);
1864
1929
  return {
1865
1930
  bookId,
1866
1931
  reverseEntry: reverse,
@@ -1924,7 +1989,7 @@ async function setOpeningBalances(input, workspaceRoot) {
1924
1989
  await appendJournal(bookId, opening, workspaceRoot);
1925
1990
  scheduleRebuild(bookId, "0000-00", workspaceRoot);
1926
1991
  await invalidateAllSnapshots(bookId, workspaceRoot);
1927
- publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.opening });
1992
+ publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.opening }, workspaceRoot);
1928
1993
  return {
1929
1994
  bookId,
1930
1995
  openingEntry: opening,
@@ -2059,7 +2124,7 @@ async function getTimeSeriesReport(input, workspaceRoot) {
2059
2124
  async function rebuildSnapshots(input, workspaceRoot) {
2060
2125
  const bookId = resolveBookId(await loadOrInitConfig(workspaceRoot), input.bookId);
2061
2126
  const result = await rebuildAllSnapshots(bookId, workspaceRoot);
2062
- publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.snapshotsReady });
2127
+ publishBookChange(bookId, { kind: BOOK_EVENT_KINDS.snapshotsReady }, workspaceRoot);
2063
2128
  return {
2064
2129
  bookId,
2065
2130
  rebuilt: result.rebuilt
@@ -2086,19 +2151,21 @@ function asyncHandler(namespace, fallbackMessage, handler) {
2086
2151
  }
2087
2152
  //#endregion
2088
2153
  //#region src/server/router.ts
2089
- async function handleOpenBook(rest) {
2154
+ async function handleOpenBook(rest, root) {
2090
2155
  if (typeof rest.bookId !== "string" || rest.bookId === "") throw new AccountingError(400, "openBook: bookId is required. Call 'getBooks' to enumerate, or 'createBook' first on a fresh workspace.");
2091
- const list = await listBooks();
2156
+ const list = await listBooks(root);
2092
2157
  if (!list.books.some((book) => book.id === rest.bookId)) throw new AccountingError(404, `openBook: book ${JSON.stringify(rest.bookId)} not found`);
2093
2158
  const initialTab = typeof rest.initialTab === "string" ? rest.initialTab : void 0;
2159
+ const scopeField = isProjectScopedHost() ? { scope: channelScopeFor(root) } : {};
2094
2160
  return {
2095
2161
  kind: "accounting-app",
2096
2162
  bookId: rest.bookId,
2097
2163
  initialTab,
2098
- books: list.books
2164
+ books: list.books,
2165
+ ...scopeField
2099
2166
  };
2100
2167
  }
2101
- async function handleGetReport(rest) {
2168
+ async function handleGetReport(rest, root) {
2102
2169
  const kind = typeof rest.kind === "string" ? rest.kind : "";
2103
2170
  const periodInput = optionalReportPeriod(rest.period);
2104
2171
  const bookId = optionalString(rest.bookId);
@@ -2109,14 +2176,14 @@ async function handleGetReport(rest) {
2109
2176
  return getBalanceSheetReport({
2110
2177
  bookId,
2111
2178
  period: periodInput
2112
- });
2179
+ }, root);
2113
2180
  }
2114
2181
  if (kind === "pl") {
2115
2182
  if (!periodInput) throw periodRequired("getReport pl");
2116
2183
  return getProfitLossReport({
2117
2184
  bookId,
2118
2185
  period: periodInput
2119
- });
2186
+ }, root);
2120
2187
  }
2121
2188
  if (kind === "ledger") {
2122
2189
  if (typeof rest.accountCode !== "string" || rest.accountCode === "") throw new AccountingError(400, "getReport ledger: accountCode is required");
@@ -2124,79 +2191,79 @@ async function handleGetReport(rest) {
2124
2191
  bookId,
2125
2192
  accountCode: rest.accountCode,
2126
2193
  period: periodInput
2127
- });
2194
+ }, root);
2128
2195
  }
2129
2196
  throw new AccountingError(400, `getReport: unknown kind ${JSON.stringify(kind)}`);
2130
2197
  }
2131
2198
  var ACTION_HANDLERS = {
2132
2199
  [ACCOUNTING_ACTIONS.openBook]: handleOpenBook,
2133
- [ACCOUNTING_ACTIONS.getBooks]: () => listBooks(),
2134
- [ACCOUNTING_ACTIONS.createBook]: async (rest) => {
2200
+ [ACCOUNTING_ACTIONS.getBooks]: (_rest, root) => listBooks(root),
2201
+ [ACCOUNTING_ACTIONS.createBook]: async (rest, root) => {
2135
2202
  const result = await createBook({
2136
2203
  name: typeof rest.name === "string" ? rest.name : "",
2137
2204
  currency: typeof rest.currency === "string" ? rest.currency : void 0,
2138
2205
  country: typeof rest.country === "string" ? rest.country : void 0,
2139
2206
  fiscalYearEnd: rest.fiscalYearEnd
2140
- });
2207
+ }, root);
2141
2208
  return {
2142
2209
  bookId: result.book.id,
2143
2210
  ...result
2144
2211
  };
2145
2212
  },
2146
- [ACCOUNTING_ACTIONS.updateBook]: async (rest) => {
2213
+ [ACCOUNTING_ACTIONS.updateBook]: async (rest, root) => {
2147
2214
  const result = await updateBook({
2148
2215
  bookId: typeof rest.bookId === "string" ? rest.bookId : "",
2149
2216
  name: typeof rest.name === "string" ? rest.name : void 0,
2150
2217
  country: typeof rest.country === "string" ? rest.country : void 0,
2151
2218
  fiscalYearEnd: rest.fiscalYearEnd
2152
- });
2219
+ }, root);
2153
2220
  return {
2154
2221
  bookId: result.book.id,
2155
2222
  ...result
2156
2223
  };
2157
2224
  },
2158
- [ACCOUNTING_ACTIONS.deleteBook]: (rest) => deleteBook({
2225
+ [ACCOUNTING_ACTIONS.deleteBook]: (rest, root) => deleteBook({
2159
2226
  bookId: typeof rest.bookId === "string" ? rest.bookId : "",
2160
2227
  confirm: rest.confirm === true
2161
- }),
2162
- [ACCOUNTING_ACTIONS.getAccounts]: (rest) => listAccounts({ bookId: optionalString(rest.bookId) }),
2163
- [ACCOUNTING_ACTIONS.upsertAccount]: (rest) => upsertAccount({
2228
+ }, root),
2229
+ [ACCOUNTING_ACTIONS.getAccounts]: (rest, root) => listAccounts({ bookId: optionalString(rest.bookId) }, root),
2230
+ [ACCOUNTING_ACTIONS.upsertAccount]: (rest, root) => upsertAccount({
2164
2231
  bookId: optionalString(rest.bookId),
2165
2232
  account: rest.account
2166
- }),
2167
- [ACCOUNTING_ACTIONS.addEntries]: (rest) => addEntries({
2233
+ }, root),
2234
+ [ACCOUNTING_ACTIONS.addEntries]: (rest, root) => addEntries({
2168
2235
  bookId: optionalString(rest.bookId),
2169
2236
  entries: rest.entries
2170
- }),
2171
- [ACCOUNTING_ACTIONS.voidEntry]: (rest) => voidEntry({
2237
+ }, root),
2238
+ [ACCOUNTING_ACTIONS.voidEntry]: (rest, root) => voidEntry({
2172
2239
  bookId: optionalString(rest.bookId),
2173
2240
  entryId: typeof rest.entryId === "string" ? rest.entryId : "",
2174
2241
  reason: optionalString(rest.reason),
2175
2242
  voidDate: optionalString(rest.voidDate)
2176
- }),
2177
- [ACCOUNTING_ACTIONS.getJournalEntries]: (rest) => listEntries({
2243
+ }, root),
2244
+ [ACCOUNTING_ACTIONS.getJournalEntries]: (rest, root) => listEntries({
2178
2245
  bookId: optionalString(rest.bookId),
2179
2246
  from: optionalString(rest.from),
2180
2247
  to: optionalString(rest.to),
2181
2248
  accountCode: optionalString(rest.accountCode)
2182
- }),
2183
- [ACCOUNTING_ACTIONS.getOpeningBalances]: (rest) => getOpeningBalances({ bookId: optionalString(rest.bookId) }),
2184
- [ACCOUNTING_ACTIONS.setOpeningBalances]: (rest) => setOpeningBalances({
2249
+ }, root),
2250
+ [ACCOUNTING_ACTIONS.getOpeningBalances]: (rest, root) => getOpeningBalances({ bookId: optionalString(rest.bookId) }, root),
2251
+ [ACCOUNTING_ACTIONS.setOpeningBalances]: (rest, root) => setOpeningBalances({
2185
2252
  bookId: optionalString(rest.bookId),
2186
2253
  asOfDate: typeof rest.asOfDate === "string" ? rest.asOfDate : "",
2187
2254
  lines: rest.lines ?? [],
2188
2255
  memo: optionalString(rest.memo)
2189
- }),
2256
+ }, root),
2190
2257
  [ACCOUNTING_ACTIONS.getReport]: handleGetReport,
2191
- [ACCOUNTING_ACTIONS.getTimeSeries]: (rest) => getTimeSeriesReport({
2258
+ [ACCOUNTING_ACTIONS.getTimeSeries]: (rest, root) => getTimeSeriesReport({
2192
2259
  bookId: optionalString(rest.bookId),
2193
2260
  metric: rest.metric,
2194
2261
  granularity: rest.granularity,
2195
2262
  from: rest.from,
2196
2263
  to: rest.to,
2197
2264
  accountCode: rest.accountCode
2198
- }),
2199
- [ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest) => rebuildSnapshots({ bookId: optionalString(rest.bookId) })
2265
+ }, root),
2266
+ [ACCOUNTING_ACTIONS.rebuildSnapshots]: (rest, root) => rebuildSnapshots({ bookId: optionalString(rest.bookId) }, root)
2200
2267
  };
2201
2268
  var PREVIEW_ACTIONS = /* @__PURE__ */ new Set([
2202
2269
  ACCOUNTING_ACTIONS.openBook,
@@ -2273,11 +2340,11 @@ function previewMessage(action, fields) {
2273
2340
  const head = ownEntry(MESSAGE_BUILDERS, action)?.(fields);
2274
2341
  return head ? `${head} ${VIEW_VISIBLE_TRAILER}` : VIEW_VISIBLE_TRAILER;
2275
2342
  }
2276
- async function dispatch(body) {
2343
+ async function dispatch(body, root) {
2277
2344
  const { action, ...rest } = body;
2278
2345
  const handler = ownEntry(ACTION_HANDLERS, action);
2279
2346
  if (!handler) throw new AccountingError(400, `unknown action ${JSON.stringify(action)}`);
2280
- const result = await handler(rest);
2347
+ const result = await handler(rest, root);
2281
2348
  const handlerFields = isRecord(result) ? result : { value: result };
2282
2349
  const dataField = PREVIEW_ACTIONS.has(action) ? { data: {
2283
2350
  action,
@@ -2294,8 +2361,9 @@ async function dispatch(body) {
2294
2361
  /** Build the accounting Express router. The host injects its workspace
2295
2362
  * root + logger via `configureAccountingServer(...)` and pub/sub via
2296
2363
  * `initAccountingEventPublisher(...)`, then mounts the returned router
2297
- * with `app.use(...)`. */
2298
- function createAccountingRouter() {
2364
+ * with `app.use(...)`. Pass `resolveWorkspaceRoot` to serve more than
2365
+ * one root; omit it and every request uses the configured one. */
2366
+ function createAccountingRouter(options = {}) {
2299
2367
  const router = Router();
2300
2368
  router.post(ACCOUNTING_API.dispatch.path, asyncHandler("accounting", "accounting dispatch failed", async (req, res) => {
2301
2369
  const { body } = req;
@@ -2307,7 +2375,8 @@ function createAccountingRouter() {
2307
2375
  const { action } = body;
2308
2376
  log.info("accounting", "POST dispatch: start", { action });
2309
2377
  try {
2310
- const result = await dispatch(body);
2378
+ const root = options.resolveWorkspaceRoot?.(req);
2379
+ const result = await dispatch(body, root);
2311
2380
  log.info("accounting", "POST dispatch: ok", { action });
2312
2381
  res.json(result);
2313
2382
  } catch (err) {