@bevel-software/platform-core-backend 0.36.1 → 0.37.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 (71) hide show
  1. package/dist/core/create-core-services.d.ts.map +1 -1
  2. package/dist/core/create-core-services.js +5 -2
  3. package/dist/core/create-core-services.js.map +1 -1
  4. package/dist/modules/database/connection.d.ts.map +1 -1
  5. package/dist/modules/database/connection.js +11 -0
  6. package/dist/modules/database/connection.js.map +1 -1
  7. package/dist/modules/kb-fs/file-change-notifier.d.ts +51 -26
  8. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  9. package/dist/modules/kb-fs/file-change-notifier.js +6 -8
  10. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.d.ts +0 -7
  12. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  13. package/dist/modules/kb-fs/locking-filesystem.js +4 -12
  14. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  15. package/dist/modules/onboarding/starter-pack.service.d.ts +1 -3
  16. package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -1
  17. package/dist/modules/onboarding/starter-pack.service.js +1 -1
  18. package/dist/modules/onboarding/starter-pack.service.js.map +1 -1
  19. package/dist/modules/version-control/git/git.service.d.ts +18 -0
  20. package/dist/modules/version-control/git/git.service.d.ts.map +1 -1
  21. package/dist/modules/version-control/git/git.service.js +53 -2
  22. package/dist/modules/version-control/git/git.service.js.map +1 -1
  23. package/dist/modules/version-control/version-control-hooks.d.ts +29 -2
  24. package/dist/modules/version-control/version-control-hooks.d.ts.map +1 -1
  25. package/dist/modules/version-control/version-control-hooks.js +17 -0
  26. package/dist/modules/version-control/version-control-hooks.js.map +1 -1
  27. package/dist/modules/version-control/version-control.service.d.ts +0 -15
  28. package/dist/modules/version-control/version-control.service.d.ts.map +1 -1
  29. package/dist/modules/version-control/version-control.service.js +4 -13
  30. package/dist/modules/version-control/version-control.service.js.map +1 -1
  31. package/dist/shared/fire-and-forget.d.ts +14 -0
  32. package/dist/shared/fire-and-forget.d.ts.map +1 -0
  33. package/dist/shared/fire-and-forget.js +23 -0
  34. package/dist/shared/fire-and-forget.js.map +1 -0
  35. package/package.json +3 -3
  36. package/src/__tests__/capturing-logger.ts +36 -0
  37. package/src/__tests__/real-access.ts +16 -0
  38. package/src/__tests__/real-auth.ts +13 -0
  39. package/src/__tests__/real-readers.ts +27 -0
  40. package/src/__tests__/real-version-control.ts +22 -0
  41. package/src/core/__tests__/commit-announcements-wired-once.test.ts +53 -0
  42. package/src/core/create-core-services.ts +4 -2
  43. package/src/modules/access/__tests__/access-requests.routes.test.ts +3 -3
  44. package/src/modules/access/__tests__/groups-admin.service.test.ts +20 -20
  45. package/src/modules/access/__tests__/roles-admin.service.test.ts +12 -12
  46. package/src/modules/access/__tests__/roles-capabilities.test.ts +11 -11
  47. package/src/modules/access/__tests__/roles.routes.test.ts +8 -8
  48. package/src/modules/access/__tests__/synced-groups-committer.test.ts +2 -2
  49. package/src/modules/access/__tests__/user-access-removal.service.test.ts +6 -6
  50. package/src/modules/database/__tests__/connection.pg.test.ts +55 -0
  51. package/src/modules/database/__tests__/connection.test.ts +30 -0
  52. package/src/modules/database/connection.ts +12 -0
  53. package/src/modules/embed/__tests__/embed.service.test.ts +6 -20
  54. package/src/modules/kb-fs/__tests__/file-change-notifier.test.ts +23 -4
  55. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +8 -49
  56. package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +73 -10
  57. package/src/modules/kb-fs/file-change-notifier.ts +55 -32
  58. package/src/modules/kb-fs/locking-filesystem.ts +4 -18
  59. package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +4 -4
  60. package/src/modules/onboarding/starter-pack.service.ts +2 -4
  61. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +46 -21
  62. package/src/modules/plugins/__tests__/plugins.routes.test.ts +6 -6
  63. package/src/modules/version-control/__tests__/version-control.service.facade.test.ts +4 -4
  64. package/src/modules/version-control/__tests__/version-control.service.releaseLock.test.ts +43 -33
  65. package/src/modules/version-control/git/__tests__/git.service.commitLanded.test.ts +199 -0
  66. package/src/modules/version-control/git/__tests__/git.service.mergeChangeRequest.test.ts +79 -2
  67. package/src/modules/version-control/git/git.service.ts +55 -2
  68. package/src/modules/version-control/version-control-hooks.ts +41 -2
  69. package/src/modules/version-control/version-control.service.ts +4 -12
  70. package/src/shared/__tests__/fire-and-forget.test.ts +39 -0
  71. package/src/shared/fire-and-forget.ts +26 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fire-and-forget.js","sourceRoot":"","sources":["../../src/shared/fire-and-forget.ts"],"names":[],"mappings":"AAKA;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAI,QAAkC,EAAE,KAAQ,EAAE,GAAY,EAAE,MAAc;IAC7G,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,QAAQ,CAAC,KAAK,CAAC,CAAC;QACxC,IAAI,aAAa,CAAC,MAAM,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,GAAY,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;IAClG,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;IAC7B,CAAC;AACH,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,OAAQ,KAA8B,CAAC,IAAI,KAAK,UAAU,CAAC;AACnH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, version control (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
56
56
  "yaml": "^2.9.0",
57
57
  "zod": "^3.24.0",
58
- "@bevel-software/platform-mcp-core": "0.36.1",
59
- "@bevel-software/platform-shared": "0.36.1"
58
+ "@bevel-software/platform-mcp-core": "0.37.0",
59
+ "@bevel-software/platform-shared": "0.37.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,36 @@
1
+ import type { ILogger, LogFields } from '../shared/logger.contract.js';
2
+
3
+ /** One line a capturing logger kept: its level, message, and fields with the child bindings folded in. */
4
+ export interface CapturedLine {
5
+ level: 'debug' | 'info' | 'warn' | 'error';
6
+ message: string;
7
+ fields: LogFields;
8
+ }
9
+
10
+ /**
11
+ * A logger that keeps every line instead of printing it — the one capturing
12
+ * implementation a suite installs through `setLogger` (restoring the previous
13
+ * one in `finally`) or hands to code that takes an `ILogger`, when what is
14
+ * logged is the behaviour under test. Children fold their bindings into each
15
+ * line's fields, so `module: 'database'` is asserted like any other field.
16
+ */
17
+ export function capturingLogger(): { logger: ILogger; lines: CapturedLine[] } {
18
+ const lines: CapturedLine[] = [];
19
+ const make = (bindings: LogFields): ILogger => {
20
+ const line = (level: CapturedLine['level']) => (message: string, fields: LogFields = {}) => {
21
+ // Like the production sink, the child's `module` is the logger's own and
22
+ // a call cannot rename it; every other field a call passes wins.
23
+ const folded: LogFields = { ...bindings, ...fields };
24
+ if (bindings.module !== undefined) folded.module = bindings.module;
25
+ lines.push({ level, message, fields: folded });
26
+ };
27
+ return {
28
+ debug: line('debug'),
29
+ info: line('info'),
30
+ warn: line('warn'),
31
+ error: line('error'),
32
+ child: (more) => make({ ...bindings, ...more }),
33
+ };
34
+ };
35
+ return { logger: make({}), lines };
36
+ }
@@ -0,0 +1,16 @@
1
+ import type { ITreeWalker } from '../shared/fs.contract.js';
2
+ import type { WorkspaceService } from '../modules/workspace/index.js';
3
+ import { AccessControlService } from '../modules/access/access-control.service.js';
4
+
5
+ /**
6
+ * The access resolver over a real tree, wired the way the composition root
7
+ * wires it (`core/create-core-services.ts`). A module's suite whose
8
+ * assertions are what the real rules answer after its service ran — who may
9
+ * read, who may write — takes it from HERE rather than constructing another
10
+ * module's implementation itself: the service is on no module entry, by
11
+ * design (a module receives `IAccessControl` by injection), and this fixture
12
+ * stands in for the root, as `real-disk.ts` does for the disk.
13
+ */
14
+ export function realAccessControl(workspace: WorkspaceService, kbDirName: string, disk: ITreeWalker): AccessControlService {
15
+ return new AccessControlService(workspace, kbDirName, disk);
16
+ }
@@ -0,0 +1,13 @@
1
+ import { AuthService } from '../modules/auth/auth.service.js';
2
+
3
+ /**
4
+ * The auth service, constructed the way the composition root constructs it
5
+ * (`core/create-core-services.ts`). A module's suite that drives routes which
6
+ * take the real service — the access routes, which it guards — takes it from
7
+ * HERE rather than constructing another module's implementation itself: the
8
+ * auth entry holds the service's contract and the class behind it is the
9
+ * root's to wire, as `real-disk.ts` stands in for the root for the disk.
10
+ */
11
+ export function realAuthService(...args: ConstructorParameters<typeof AuthService>): AuthService {
12
+ return new AuthService(...args);
13
+ }
@@ -0,0 +1,27 @@
1
+ import type { FileReaderRegistry } from '../modules/workspace/index.js';
2
+ import { createFileReaderRegistry } from '../modules/workspace/file-readers/file-reader.registry.js';
3
+ import type { DocExtractOutcome, DocExtractService } from '../modules/workspace/file-readers/doc-extract.service.js';
4
+
5
+ /**
6
+ * The file readers, composed the way the composition root composes them
7
+ * (`core/create-core-services.ts`) over a document extractor. A module's suite
8
+ * that reads real files through them takes them from HERE rather than
9
+ * composing another module's internals itself: the registry factory is on no
10
+ * module entry, by design (a module receives the registry by injection), and
11
+ * this fixture stands in for the root, as `real-disk.ts` does for the disk.
12
+ */
13
+ export function realFileReaders(docExtract: DocExtractService = noDocExtract()): FileReaderRegistry {
14
+ return createFileReaderRegistry(docExtract);
15
+ }
16
+
17
+ /**
18
+ * A document extractor that extracts nothing: a suite that reads no
19
+ * document-format file gets this typed refusal, not a silent empty text.
20
+ */
21
+ export function noDocExtract(): DocExtractService {
22
+ const extract = async (): Promise<DocExtractOutcome> => ({
23
+ ok: false,
24
+ message: 'this suite reads no document-format file',
25
+ });
26
+ return { extract } satisfies Pick<DocExtractService, 'extract'> as DocExtractService;
27
+ }
@@ -0,0 +1,22 @@
1
+ import type { KbContext } from '../shared/kb-context.js';
2
+ import type { WorkspaceService } from '../modules/workspace/index.js';
3
+ import { GitService } from '../modules/version-control/git/git.service.js';
4
+ import { VersionControlHooks } from '../modules/version-control/version-control-hooks.js';
5
+
6
+ /**
7
+ * The version-control collaborators, wired the way the composition root wires
8
+ * them (`core/create-core-services.ts`): the git service over a workspace and
9
+ * the lifecycle hooks it consults — where every commit it lands is reported
10
+ * (`onCommitLanded`). A module's suite that commits to a real repository
11
+ * takes them from HERE rather than constructing another module's
12
+ * implementation itself: none of them is on a module entry, by design — they
13
+ * arrive by injection — and this fixture stands in for the root, as
14
+ * `real-disk.ts` does for the disk.
15
+ */
16
+ export function realVersionControlHooks(): VersionControlHooks {
17
+ return new VersionControlHooks();
18
+ }
19
+
20
+ export function realGitService(workspace: WorkspaceService, hooks: VersionControlHooks, kb: KbContext): GitService {
21
+ return new GitService(workspace, hooks, kb);
22
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ /**
7
+ * A landed commit is announced from ONE place. The git service reports every
8
+ * commit it lands through `VersionControlHooks.commitLanded`; the composition
9
+ * root hands that to the file-change notifier with one registration; nothing
10
+ * else emits on the notifier or calls the hook. A second announcer is how a
11
+ * batch gets announced twice, and a committer that announces on its own is how
12
+ * the next one forgets to — both were real findings on the release that made
13
+ * this rule, so the rule is pinned at the source level.
14
+ */
15
+ const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
16
+ const ROOT = path.join(SRC, 'core', 'create-core-services.ts');
17
+
18
+ async function shippedSources(dir: string, out: string[] = []): Promise<string[]> {
19
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
20
+ const full = path.join(dir, entry.name);
21
+ if (entry.isDirectory()) {
22
+ if (entry.name !== '__tests__') await shippedSources(full, out);
23
+ } else if (/\.ts$/.test(entry.name) && !/\.test\.ts$/.test(entry.name)) out.push(full);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ async function filesContaining(needle: RegExp): Promise<string[]> {
29
+ const hits: string[] = [];
30
+ for (const file of await shippedSources(SRC)) {
31
+ if (needle.test(await fs.readFile(file, 'utf8'))) hits.push(path.relative(SRC, file).replace(/\\/g, '/'));
32
+ }
33
+ return hits.sort();
34
+ }
35
+
36
+ describe('commit announcements are wired once', () => {
37
+ it('the composition root hands the git layer\'s landed commits to the notifier with one registration', async () => {
38
+ const source = await fs.readFile(ROOT, 'utf8');
39
+ const registrations = source.match(/versionControlHooks\.onCommitLanded\(/g) ?? [];
40
+ expect(registrations).toHaveLength(1);
41
+ expect(source).toContain('versionControlHooks.onCommitLanded((change) => fileChangeNotifier.emit(change));');
42
+ });
43
+
44
+ it('only the git service reports a landed commit, and only the root registers for it', async () => {
45
+ expect(await filesContaining(/\.commitLanded\(/)).toEqual(['modules/version-control/git/git.service.ts']);
46
+ expect(await filesContaining(/\.onCommitLanded\(/)).toEqual(['core/create-core-services.ts']);
47
+ });
48
+
49
+ it('nothing but the root emits on the notifier, and no committer announces on its own', async () => {
50
+ expect(await filesContaining(/fileChangeNotifier\.emit\(|fileChanges\??\.emit\(/)).toEqual(['core/create-core-services.ts']);
51
+ expect(await filesContaining(/announceCommitted|announceReleased/)).toEqual([]);
52
+ });
53
+ });
@@ -1024,6 +1024,10 @@ export async function createCoreServices(
1024
1024
  // id-repair keeps the id namespace collision-free. Subscribers are registered
1025
1025
  // below, once the reacting services exist.
1026
1026
  const fileChangeNotifier = new FileChangeNotifier();
1027
+ // Every commit the git service lands — a save, an autosave checkpoint, a
1028
+ // batch, a merge, a `commit_change` — reaches the notifier through this one
1029
+ // registration, and nothing else emits on it (a test pins both).
1030
+ versionControlHooks.onCommitLanded((change) => fileChangeNotifier.emit(change));
1027
1031
  // Who tells the reviewers of an opened change request: the composition root
1028
1032
  // of a distribution registers its listener here, after this returns (see
1029
1033
  // `ChangeRequestHooks`).
@@ -1040,7 +1044,6 @@ export async function createCoreServices(
1040
1044
  kb,
1041
1045
  changeGate,
1042
1046
  eventBus,
1043
- fileChangeNotifier,
1044
1047
  // Exposed as `versionControlService.hooks` — the SAME instance GitService and
1045
1048
  // the agent-access gate consult, so a deployment's registrations against
1046
1049
  // it reach every hook point.
@@ -1254,7 +1257,6 @@ export async function createCoreServices(
1254
1257
  pluginSource,
1255
1258
  pluginLocks: pluginProvisionService,
1256
1259
  events: eventBus,
1257
- fileChanges: fileChangeNotifier,
1258
1260
  // Its own lock: one starter-pack choice at a time, a concern no other
1259
1261
  // service's mutex serialises.
1260
1262
  choosing: new WorkspaceMutex(),
@@ -6,13 +6,13 @@ import { joinBranchFor, type IVersionControlService } from '@bevel-software/plat
6
6
 
7
7
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
8
  import { testKbContext, TEST_BRANCH_MODEL } from '../../../__tests__/kb-context.js';
9
- import type { AuthService } from '../../auth/auth.service.js';
10
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
+ import type { AuthService } from '../../auth/index.js';
10
+ import type { WorkspaceService } from '../../workspace/index.js';
11
11
  import type { IAccessControl } from '../access-control.interface.js';
12
12
  import type { IAccessRequestLifecycle } from '../access-requests.contract.js';
13
13
  import { createAccessRequestRoutes } from '../access-requests.routes.js';
14
14
  import { ChangeRequestConflictsError } from '../../../shared/domain-errors.js';
15
- import { pendingProposals } from '../../plugins/join-proposals.js';
15
+ import { pendingProposals } from '../../plugins/index.js';
16
16
 
17
17
  /**
18
18
  * Asking for Can edit or Owner on an item, and what the editors then see.
@@ -1,11 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { NodeFs } from '../../kb-fs/node-fs.js';
2
+ import { realDisk } from '../../../__tests__/real-disk.js';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
 
7
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
- import type { VersionControlService } from '../../version-control/version-control.service.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
+ import type { VersionControlService } from '../../version-control/index.js';
9
9
  import type { AuthUser, FileTreeEntry } from '@bevel-software/platform-shared';
10
10
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
11
11
  import { AccessControlService } from '../access-control.service.js';
@@ -110,8 +110,8 @@ describe('GroupsAdminService', () => {
110
110
  service = new GroupsAdminService(
111
111
  workspace,
112
112
  versionControl.svc,
113
- new AccessControlService(workspace as never, KB, new NodeFs()),
114
- new NodeFs(),
113
+ new AccessControlService(workspace as never, KB, realDisk()),
114
+ realDisk(),
115
115
  KB,
116
116
  () => DEFAULT_BRANCH,
117
117
  );
@@ -272,7 +272,7 @@ describe('GroupsAdminService', () => {
272
272
  '---\nread:\n - deny everyone\n - Sara <sara@x.io>\nwrite:\n - Sara <sara@x.io>\n---\n',
273
273
  );
274
274
  await write(repo, 'Plugins/Sara/Notes.md', "---\nread: ['group/GTM Team']\n---\n# Notes\n");
275
- const access = new AccessControlService(stubWorkspace(root) as never, KB, new NodeFs());
275
+ const access = new AccessControlService(stubWorkspace(root) as never, KB, realDisk());
276
276
  expect(await access.canRead(workspaceIdForBranch(DEFAULT_BRANCH), ADMIN.email, 'Plugins/Sara/Notes.md')).toBe(false);
277
277
 
278
278
  await service.renameGroup(ADMIN, 'gtm team', 'Go To Market');
@@ -289,7 +289,7 @@ describe('GroupsAdminService', () => {
289
289
  // `assertCanWriteAtRef`); this stub does the same through the live model.
290
290
  function gatedService(): { gated: GroupsAdminService; gatedCommits: { summary: string }[] } {
291
291
  const workspace = stubWorkspace(root);
292
- const access = new AccessControlService(workspace as never, KB, new NodeFs());
292
+ const access = new AccessControlService(workspace as never, KB, realDisk());
293
293
  const versionControl = stubVersionControl();
294
294
  const svc = versionControl.svc as unknown as Record<string, unknown>;
295
295
  svc.commitChanges = async (ws: string, user: AuthUser, summary: string, onlyPaths: string[] = []) => {
@@ -300,7 +300,7 @@ describe('GroupsAdminService', () => {
300
300
  versionControl.commits.push({ summary });
301
301
  return {} as unknown;
302
302
  };
303
- const gated = new GroupsAdminService(workspace, versionControl.svc, access, new NodeFs(), KB, () => DEFAULT_BRANCH);
303
+ const gated = new GroupsAdminService(workspace, versionControl.svc, access, realDisk(), KB, () => DEFAULT_BRANCH);
304
304
  return { gated, gatedCommits: versionControl.commits };
305
305
  }
306
306
 
@@ -395,8 +395,8 @@ describe('GroupsAdminService', () => {
395
395
  const svc = new GroupsAdminService(
396
396
  workspace,
397
397
  versionControl.svc,
398
- new AccessControlService(workspace as never, KB, new NodeFs()),
399
- new NodeFs(),
398
+ new AccessControlService(workspace as never, KB, realDisk()),
399
+ realDisk(),
400
400
  KB,
401
401
  () => DEFAULT_BRANCH,
402
402
  );
@@ -442,8 +442,8 @@ describe('GroupsAdminService', () => {
442
442
  const svc = new GroupsAdminService(
443
443
  workspace,
444
444
  versionControl.svc,
445
- new AccessControlService(workspace as never, KB, new NodeFs()),
446
- new NodeFs(),
445
+ new AccessControlService(workspace as never, KB, realDisk()),
446
+ realDisk(),
447
447
  KB,
448
448
  () => DEFAULT_BRANCH,
449
449
  );
@@ -482,8 +482,8 @@ describe('GroupsAdminService', () => {
482
482
  const svc = new GroupsAdminService(
483
483
  workspace,
484
484
  versionControl.svc,
485
- new AccessControlService(workspace as never, KB, new NodeFs()),
486
- new NodeFs(),
485
+ new AccessControlService(workspace as never, KB, realDisk()),
486
+ realDisk(),
487
487
  KB,
488
488
  () => DEFAULT_BRANCH,
489
489
  );
@@ -538,8 +538,8 @@ describe('groups roster referencedBy — the mirror of the roles roster attribut
538
538
  const svc = new GroupsAdminService(
539
539
  workspace,
540
540
  stubVersionControl().svc,
541
- new AccessControlService(workspace as never, KB, new NodeFs()),
542
- new NodeFs(),
541
+ new AccessControlService(workspace as never, KB, realDisk()),
542
+ realDisk(),
543
543
  KB,
544
544
  () => DEFAULT_BRANCH,
545
545
  );
@@ -581,8 +581,8 @@ describe('groups roster referencedBy — the mirror of the roles roster attribut
581
581
  await write(repo2, 'team/access.md', '---\nwrite:\n - Reviewer\n---\n');
582
582
  await write(repo2, 'secret/Plan.md', '---\nread:\n - deny everyone\n - Reviewer\n---\n# Plan\n');
583
583
  const workspace = stubWorkspace(root2);
584
- const access = new AccessControlService(workspace as never, KB, new NodeFs());
585
- const svc = new GroupsAdminService(workspace, stubVersionControl().svc, access, new NodeFs(), KB, () => DEFAULT_BRANCH);
584
+ const access = new AccessControlService(workspace as never, KB, realDisk());
585
+ const svc = new GroupsAdminService(workspace, stubVersionControl().svc, access, realDisk(), KB, () => DEFAULT_BRANCH);
586
586
  const rowsFor = async (email: string) =>
587
587
  (await svc.getRoster(email)).groups.find((g) => g.canonical === 'reviewer')!.bareNameResolvesToRole;
588
588
 
@@ -620,8 +620,8 @@ describe('groups roster referencedBy — the mirror of the roles roster attribut
620
620
  const svc = new GroupsAdminService(
621
621
  workspace,
622
622
  stubVersionControl().svc,
623
- new AccessControlService(workspace as never, KB, new NodeFs()),
624
- new NodeFs(),
623
+ new AccessControlService(workspace as never, KB, realDisk()),
624
+ realDisk(),
625
625
  KB,
626
626
  () => DEFAULT_BRANCH,
627
627
  );
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { NodeFs } from '../../kb-fs/node-fs.js';
2
+ import { realDisk } from '../../../__tests__/real-disk.js';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
 
7
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
- import type { VersionControlService } from '../../version-control/version-control.service.js';
9
- import type { VersionControlEventBus } from '../../version-control/event-bus.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
+ import type { VersionControlService } from '../../version-control/index.js';
9
+ import type { VersionControlEventBus } from '../../version-control/index.js';
10
10
  import type { FileTreeEntry, VersionControlEventPayload } from '@bevel-software/platform-shared';
11
11
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
12
12
  import { AccessControlService } from '../access-control.service.js';
@@ -168,10 +168,10 @@ describe('RolesAdminService', () => {
168
168
  repo = path.join(workspaceDir, KB);
169
169
  await write(repo, 'roles.yaml', ROLES);
170
170
  ws = stubWorkspace(workspaceDir);
171
- access = new AccessControlService(ws, KB, new NodeFs());
171
+ access = new AccessControlService(ws, KB, realDisk());
172
172
  versionControl = stubVersionControl();
173
173
  bus = stubEventBus();
174
- svc = new RolesAdminService(ws, versionControl.svc, access, new NodeFs(), KB, () => DEFAULT_BRANCH, bus.bus, [
174
+ svc = new RolesAdminService(ws, versionControl.svc, access, realDisk(), KB, () => DEFAULT_BRANCH, bus.bus, [
175
175
  'recovery-admin@example.com',
176
176
  ]);
177
177
  });
@@ -193,7 +193,7 @@ describe('RolesAdminService', () => {
193
193
  // `DEFAULT_BRANCH` and froze it — every roles route then failed branch
194
194
  // validation with `Invalid branch name "": empty name` until a restart.
195
195
  let branch = '';
196
- const late = new RolesAdminService(ws, versionControl.svc, access, new NodeFs(), KB, () => branch, bus.bus);
196
+ const late = new RolesAdminService(ws, versionControl.svc, access, realDisk(), KB, () => branch, bus.bus);
197
197
  branch = DEFAULT_BRANCH;
198
198
  const roster = await late.getRoster(ADMIN.email);
199
199
  expect(roster.map((r) => r.canonical)).toEqual(['admin', 'sales']);
@@ -257,7 +257,7 @@ describe('RolesAdminService', () => {
257
257
  const corrupt = 'roles:\n Admin:\n - a@x.eu\n Admin:\n - b@x.eu\n';
258
258
  await write(repo, 'roles.yaml', corrupt);
259
259
  access.invalidate(WS);
260
- const adminless = new RolesAdminService(ws, versionControl.svc, access, new NodeFs(), KB, () => DEFAULT_BRANCH, bus.bus);
260
+ const adminless = new RolesAdminService(ws, versionControl.svc, access, realDisk(), KB, () => DEFAULT_BRANCH, bus.bus);
261
261
  // 409, not 500: the route helper hides >=500 bodies behind a generic
262
262
  // message, and this break-glass refusal must reach the operator verbatim.
263
263
  await expect(adminless.recover(ADMIN)).rejects.toMatchObject({
@@ -572,8 +572,8 @@ describe('roster referencedBy under group shadowing', () => {
572
572
  svc = new RolesAdminService(
573
573
  ws,
574
574
  stubVersionControl().svc,
575
- new AccessControlService(ws, KB, new NodeFs()),
576
- new NodeFs(),
575
+ new AccessControlService(ws, KB, realDisk()),
576
+ realDisk(),
577
577
  KB,
578
578
  () => DEFAULT_BRANCH,
579
579
  stubEventBus().bus,
@@ -630,8 +630,8 @@ describe('Admin fixed members — the deployment admin', () => {
630
630
  return new RolesAdminService(
631
631
  ws,
632
632
  versionControl.svc,
633
- new AccessControlService(ws, KB, new NodeFs()),
634
- new NodeFs(),
633
+ new AccessControlService(ws, KB, realDisk()),
634
+ realDisk(),
635
635
  KB,
636
636
  () => DEFAULT_BRANCH,
637
637
  stubEventBus().bus,
@@ -1,11 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { NodeFs } from '../../kb-fs/node-fs.js';
2
+ import { realDisk } from '../../../__tests__/real-disk.js';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
 
7
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
- import type { VersionControlService } from '../../version-control/version-control.service.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
+ import type { VersionControlService } from '../../version-control/index.js';
9
9
  import type { AuthUser, FileTreeEntry } from '@bevel-software/platform-shared';
10
10
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
11
11
  import { AccessControlService } from '../access-control.service.js';
@@ -131,8 +131,8 @@ describe('RolesAdminService — capabilities, group assignment, conversion', ()
131
131
  service = new RolesAdminService(
132
132
  workspace,
133
133
  versionControl.svc,
134
- new AccessControlService(workspace as never, KB, new NodeFs()),
135
- new NodeFs(),
134
+ new AccessControlService(workspace as never, KB, realDisk()),
135
+ realDisk(),
136
136
  KB,
137
137
  () => DEFAULT_BRANCH,
138
138
  );
@@ -216,8 +216,8 @@ describe('RolesAdminService — capabilities, group assignment, conversion', ()
216
216
  const svc = new RolesAdminService(
217
217
  workspace,
218
218
  versionControl.svc,
219
- new AccessControlService(workspace as never, KB, new NodeFs()),
220
- new NodeFs(),
219
+ new AccessControlService(workspace as never, KB, realDisk()),
220
+ realDisk(),
221
221
  KB,
222
222
  () => DEFAULT_BRANCH,
223
223
  );
@@ -280,8 +280,8 @@ describe('RolesAdminService — capabilities, group assignment, conversion', ()
280
280
  const svc = new RolesAdminService(
281
281
  workspace,
282
282
  versionControl.svc,
283
- new AccessControlService(workspace as never, KB, new NodeFs()),
284
- new NodeFs(),
283
+ new AccessControlService(workspace as never, KB, realDisk()),
284
+ realDisk(),
285
285
  KB,
286
286
  () => DEFAULT_BRANCH,
287
287
  );
@@ -305,8 +305,8 @@ describe('RolesAdminService — capabilities, group assignment, conversion', ()
305
305
  const svc = new RolesAdminService(
306
306
  workspace,
307
307
  versionControl.svc,
308
- new AccessControlService(workspace as never, KB, new NodeFs()),
309
- new NodeFs(),
308
+ new AccessControlService(workspace as never, KB, realDisk()),
309
+ realDisk(),
310
310
  KB,
311
311
  () => DEFAULT_BRANCH,
312
312
  );
@@ -7,13 +7,13 @@ import express from 'express';
7
7
  import { PgDialect } from 'drizzle-orm/pg-core';
8
8
  import type { SQL } from 'drizzle-orm';
9
9
  import { describe, it, expect, afterEach, vi } from 'vitest';
10
- import { NodeFs } from '../../kb-fs/node-fs.js';
10
+ import { realDisk } from '../../../__tests__/real-disk.js';
11
11
 
12
12
  import type { IAccessControl } from '../access-control.interface.js';
13
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
14
- import { AuthService } from '../../auth/auth.service.js';
15
- import type { VersionControlService } from '../../version-control/version-control.service.js';
16
- import type { VersionControlEventBus } from '../../version-control/event-bus.js';
13
+ import type { WorkspaceService } from '../../workspace/index.js';
14
+ import { realAuthService } from '../../../__tests__/real-auth.js';
15
+ import type { VersionControlService } from '../../version-control/index.js';
16
+ import type { VersionControlEventBus } from '../../version-control/index.js';
17
17
  import type { FileTreeEntry } from '@bevel-software/platform-shared';
18
18
  import { createAccessRoutes } from '../access.routes.js';
19
19
  import { usersDbDouble } from './users-db-double.js';
@@ -64,7 +64,7 @@ async function makeHarness(
64
64
  const realAccess = new AccessControlService(
65
65
  { getWorkspacePath: async () => workspaceDir, readFile: async () => ROLES } as unknown as WorkspaceService,
66
66
  KB,
67
- new NodeFs(),
67
+ realDisk(),
68
68
  );
69
69
  const accessControl = {
70
70
  // Admin gate: the route's assertCanMutate calls canWrite('roles.yaml').
@@ -173,7 +173,7 @@ async function makeHarness(
173
173
 
174
174
  // The real auth service over the same double, so the promotion's switch
175
175
  // lock and read run as they do in production (`whileSwitchedOn`).
176
- const authService = new AuthService(db, { jwtSecret: 'test', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
176
+ const authService = realAuthService(db, { jwtSecret: 'test', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
177
177
  vi.spyOn(authService, 'getUserById').mockImplementation(async () => ADMIN);
178
178
 
179
179
  const app = express();
@@ -182,7 +182,7 @@ async function makeHarness(
182
182
  (req as unknown as { userId: string }).userId = ADMIN.id;
183
183
  next();
184
184
  });
185
- app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, versionControlService, eventBus, db, testKbContext({ kbDirName: KB }), new NodeFs()));
185
+ app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, versionControlService, eventBus, db, testKbContext({ kbDirName: KB }), realDisk()));
186
186
 
187
187
  const server = await new Promise<Server>((resolve) => {
188
188
  const s = app.listen(0, () => resolve(s));
@@ -4,9 +4,9 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
 
6
6
  import type { AuthUser, IVersionControlService } from '@bevel-software/platform-shared';
7
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
8
  import type { AccessControlService } from '../access-control.service.js';
9
- import type { VersionControlEventBus } from '../../version-control/event-bus.js';
9
+ import type { VersionControlEventBus } from '../../version-control/index.js';
10
10
  import { createSyncedGroupsCommitter } from '../synced-groups-committer.js';
11
11
  import { PushNeedsAgentResolutionError, VersionControlValidationError } from '../../../shared/domain-errors.js';
12
12
 
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { NodeFs } from '../../kb-fs/node-fs.js';
2
+ import { realDisk } from '../../../__tests__/real-disk.js';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
- import type { VersionControlService } from '../../version-control/version-control.service.js';
8
+ import type { WorkspaceService } from '../../workspace/index.js';
9
+ import type { VersionControlService } from '../../version-control/index.js';
10
10
  import type { AuthUser } from '@bevel-software/platform-shared';
11
11
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
12
12
  import { PushNeedsAgentResolutionError } from '../../../shared/domain-errors.js';
@@ -167,8 +167,8 @@ describe('UserAccessRemovalService', () => {
167
167
 
168
168
  const read = (rel: string) => fs.readFile(path.join(repo, rel), 'utf-8');
169
169
 
170
- function build(versionControl = stubVersionControl(), owners: string[] = ['owner@x.io'], disk: ITreeWalker = new NodeFs()) {
171
- const access = new AccessControlService(workspace as never, KB, new NodeFs());
170
+ function build(versionControl = stubVersionControl(), owners: string[] = ['owner@x.io'], disk: ITreeWalker = realDisk()) {
171
+ const access = new AccessControlService(workspace as never, KB, realDisk());
172
172
  return {
173
173
  versionControl,
174
174
  service: new UserAccessRemovalService(workspace, versionControl.svc, access, disk, KB, () => DEFAULT_BRANCH, undefined, owners),
@@ -356,7 +356,7 @@ describe('UserAccessRemovalService', () => {
356
356
  });
357
357
 
358
358
  it('refuses to commit a partial cleanup when a folder cannot be listed', async () => {
359
- const real = new NodeFs();
359
+ const real = realDisk();
360
360
  const holey = {
361
361
  walkKb: async (...args: Parameters<ITreeWalker['walkKb']>) => {
362
362
  const result = await real.walkKb(...args);
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sql } from 'drizzle-orm';
3
+ import { closeDb, createDb } from '../connection.js';
4
+ import { setLogger } from '../../../shared/logging.js';
5
+ import { capturingLogger } from '../../../__tests__/capturing-logger.js';
6
+ import { PG_ADMIN_URL, PG_TEST_TIMEOUT, withPgAdmin } from '../../../__tests__/pg-scratch.js';
7
+
8
+ /**
9
+ * The recovery the pool's error listener promises, on a real Postgres: a
10
+ * pooled client the server terminates while it idles — a restart, a forced
11
+ * database drop, an administrator's `pg_terminate_backend` — is logged and
12
+ * let go, and the next query opens a fresh connection and succeeds. The unit
13
+ * case in `connection.test.ts` pins the listener on a pool that never
14
+ * connected; this one pins what the listener is for.
15
+ *
16
+ * Runs where `TEST_DATABASE_URL` names a server; only connects to the
17
+ * database it names and terminates this test's own backend. Writes nothing.
18
+ */
19
+ describe.skipIf(!PG_ADMIN_URL)('a pooled connection the server terminates', () => {
20
+ it(
21
+ 'is logged with the error, and the next query succeeds on a new connection',
22
+ async () => {
23
+ const { logger: capturing, lines } = capturingLogger();
24
+ const previous = setLogger(capturing);
25
+ const db = createDb(PG_ADMIN_URL!);
26
+ try {
27
+ const backendPid = async () => Number((await db.execute(sql`select pg_backend_pid() as pid`)).rows[0]!.pid);
28
+ // The query's client now idles in the pool; the server ends it.
29
+ const first = await backendPid();
30
+ const noticed = new Promise<void>((resolve) => db.$client.once('error', () => resolve()));
31
+ // The signal is checked before the wait: a backend that was not
32
+ // terminated would otherwise surface as a timeout, not as the cause.
33
+ const { rows } = await withPgAdmin((admin) =>
34
+ admin.query<{ pg_terminate_backend: boolean }>('select pg_terminate_backend($1)', [first]),
35
+ );
36
+ expect(rows[0]?.pg_terminate_backend).toBe(true);
37
+ await noticed;
38
+
39
+ const second = await backendPid();
40
+ expect(second).not.toBe(first);
41
+ expect(lines).toEqual([
42
+ {
43
+ level: 'warn',
44
+ message: 'an idle database connection was closed by the server; the next query opens a new one:',
45
+ fields: { module: 'database', err: expect.objectContaining({ code: '57P01' }) },
46
+ },
47
+ ]);
48
+ } finally {
49
+ await closeDb(db);
50
+ setLogger(previous);
51
+ }
52
+ },
53
+ PG_TEST_TIMEOUT,
54
+ );
55
+ });