@lowdefy/server-dev 7.0.0 → 7.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.
Files changed (35) hide show
  1. package/lib/docs/dataSets/checkSharedCollectionIds.js +20 -9
  2. package/lib/docs/dataSets/dataSetJourneys.chromium.mongodb.test.mjs +53 -5
  3. package/lib/docs/dataSets/describeTargetKey.js +29 -0
  4. package/lib/docs/dataSets/groupDataSetByCollection.js +19 -11
  5. package/lib/docs/dataSets/openDataSession.js +32 -18
  6. package/lib/docs/dataSets/openDataSession.mongodb.test.mjs +150 -0
  7. package/lib/docs/dataSets/readDataSet.js +34 -19
  8. package/lib/docs/dataSets/readDataSet.test.mjs +56 -25
  9. package/lib/docs/dataSets/sessionDatabaseName.js +32 -0
  10. package/lib/docs/dataSets/sweepOrphanDatabases.js +7 -4
  11. package/lib/docs/dataSets/test/app/lowdefy.yaml +22 -0
  12. package/lib/docs/dataSets/test/app/tests/data/shop.yaml +4 -0
  13. package/lib/docs/devToolDefinitions.js +3 -3
  14. package/lib/docs/getBrowser.js +1 -1
  15. package/lib/docs/getBuildStatus.js +29 -3
  16. package/lib/docs/getBuildStatus.test.mjs +99 -3
  17. package/lib/docs/getOverview.js +2 -2
  18. package/lib/docs/rails/findExternalBlocks.js +3 -3
  19. package/lib/docs/runJourneyDataSet.test.mjs +1 -1
  20. package/lib/docs/withDataSession.js +2 -2
  21. package/lib/server/applyDataSetRedirect.js +14 -5
  22. package/lib/server/applyDataSetRedirect.test.mjs +119 -13
  23. package/lib/server/applyDataSetRedirectTenantReach.test.mjs +4 -1
  24. package/lib/server/applyDataSetRedirectWalled.test.mjs +4 -1
  25. package/lib/server/createLowdefyContext.js +3 -3
  26. package/lib/server/createLowdefyContext.test.mjs +6 -3
  27. package/lib/server/mutantDataSetWrappers.test.mjs +4 -1
  28. package/manager/getContext.mjs +2 -2
  29. package/manager/processes/initialBuild.mjs +3 -1
  30. package/manager/processes/initialBuild.test.mjs +3 -0
  31. package/manager/processes/lowdefyBuild.mjs +4 -3
  32. package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +10 -0
  33. package/package.json +37 -37
  34. package/package.original.json +37 -37
  35. package/lib/docs/dataSets/findCollectionCollisions.js +0 -52
@@ -84,9 +84,11 @@ test('readDataSet reads module connections, which the build writes under their m
84
84
  writeConnection('contacts/contacts', mongo({ collection: 'contacts' }));
85
85
  writeConnection('crm-contacts', mongo({ collection: 'contacts', databaseName: 'crm' }));
86
86
  writeDataSet('alpha', 'fixtures:\n contacts/contacts: [{ _id: c1 }]\n');
87
- await expect(readDataSet({ configDirectory, buildDirectory, name: 'alpha' })).rejects.toThrow(
88
- 'Data set "alpha": Connections "contacts/contacts" and "crm-contacts" both name collection "contacts" in different databases'
89
- );
87
+ const dataSet = await readDataSet({ configDirectory, buildDirectory, name: 'alpha' });
88
+ expect(dataSet.databaseTargets).toEqual([
89
+ JSON.stringify(['secret:MONGODB_URI', null]),
90
+ JSON.stringify(['secret:MONGODB_URI', 'crm']),
91
+ ]);
90
92
  });
91
93
 
92
94
  test('readDataSet maps a module connection to its collection', async () => {
@@ -127,37 +129,66 @@ test('readDataSet refuses an indexed connection whose databaseName is an operato
127
129
  );
128
130
  });
129
131
 
130
- test('readDataSet refuses a keyed connection naming one collection under a different databaseName from another connection', async () => {
131
- writeConnection('tickets', mongo({ collection: 'tickets', databaseName: 'app' }));
132
- writeConnection('legacy-tickets', mongo({ collection: 'tickets', databaseName: 'legacy' }));
133
- writeDataSet('alpha', 'fixtures:\n tickets: [{ _id: a }]\n');
134
- await expect(readDataSet({ configDirectory, buildDirectory, name: 'alpha' })).rejects.toThrow(
135
- 'Data set "alpha": Connections "legacy-tickets" and "tickets" both name collection "tickets" in different databases'
136
- );
137
- });
138
-
139
- test('readDataSet refuses a keyed connection naming one collection under a different databaseUri secret from another connection', async () => {
132
+ test('readDataSet maps each keyed connection to its database target', async () => {
140
133
  writeConnection('tickets', mongo({ collection: 'tickets' }));
134
+ writeConnection('legacy-tickets', mongo({ collection: 'tickets', databaseName: 'legacy' }));
141
135
  writeConnection('reporting', {
142
136
  type: 'MongoDBCollection',
143
137
  properties: { databaseUri: { _secret: 'REPORTING_URI' }, collection: 'tickets' },
144
138
  });
145
- writeDataSet('alpha', 'fixtures:\n tickets: [{ _id: a }]\n');
146
- await expect(readDataSet({ configDirectory, buildDirectory, name: 'alpha' })).rejects.toThrow(
147
- 'Connections "reporting" and "tickets" both name collection "tickets" in different databases'
139
+ writeConnection('computed', {
140
+ type: 'MongoDBCollection',
141
+ properties: { databaseUri: { _env: 'URI' }, collection: 'tickets' },
142
+ });
143
+ writeConnection('api', { type: 'AxiosHttp', properties: { baseURL: 'https://example.com' } });
144
+ writeDataSet(
145
+ 'alpha',
146
+ 'fixtures:\n tickets: [{ _id: a }]\n legacy-tickets: [{ _id: a }]\n reporting: [{ _id: a }]\n computed: [{ _id: b }]\n'
148
147
  );
148
+ const dataSet = await readDataSet({ configDirectory, buildDirectory, name: 'alpha' });
149
+ expect(dataSet.targets).toEqual({
150
+ tickets: JSON.stringify(['secret:MONGODB_URI', null]),
151
+ 'legacy-tickets': JSON.stringify(['secret:MONGODB_URI', 'legacy']),
152
+ reporting: JSON.stringify(['secret:REPORTING_URI', null]),
153
+ // Computed per request: the first target's, the database it reads.
154
+ computed: JSON.stringify(['secret:MONGODB_URI', null]),
155
+ });
156
+ // Every target in the build, sorted, whether or not the data set names it.
157
+ expect(dataSet.databaseTargets).toEqual([
158
+ JSON.stringify(['secret:MONGODB_URI', null]),
159
+ JSON.stringify(['secret:MONGODB_URI', 'legacy']),
160
+ JSON.stringify(['secret:REPORTING_URI', null]),
161
+ ]);
162
+ expect(dataSet.warnings).toEqual([]);
149
163
  });
150
164
 
151
- test('readDataSet warns on the same collision between two unkeyed connections', async () => {
152
- writeConnection('tickets', mongo({ collection: 'tickets' }));
153
- writeConnection('a', mongo({ collection: 'events', databaseName: 'one' }));
154
- writeConnection('b', mongo({ collection: 'events', databaseName: 'two' }));
155
- writeConnection('computed', mongo({ collection: { _state: 'c' }, databaseName: 'three' }));
156
- writeDataSet('alpha', 'fixtures:\n tickets: [{ _id: a }]\n');
165
+ test('readDataSet accepts one _id generated on one collection in two database targets', async () => {
166
+ writeConnection('users', mongo({ collection: 'users' }));
167
+ writeConnection('auth-users', {
168
+ type: 'MongoDBCollection',
169
+ properties: { databaseUri: { _secret: 'AUTH_URI' }, collection: 'users' },
170
+ });
171
+ writeDataSet(
172
+ 'alpha',
173
+ 'fixtures:\n auth-users: [{ _id: users-1 }]\ngenerate:\n seed: 1\n users:\n count: 1\n'
174
+ );
157
175
  const dataSet = await readDataSet({ configDirectory, buildDirectory, name: 'alpha' });
158
- expect(dataSet.warnings).toEqual([
159
- 'Connections "a" and "b" both name collection "events" in different databases; under a data set they read one database, so they share one collection.',
160
- ]);
176
+ expect(dataSet.collections).toEqual({ users: 'users', 'auth-users': 'users' });
177
+ });
178
+
179
+ test('readDataSet refuses a generated _id a computed-target connection also holds on the first target', async () => {
180
+ writeConnection('users', mongo({ collection: 'users' }));
181
+ writeConnection('computed-users', {
182
+ type: 'MongoDBCollection',
183
+ properties: { databaseUri: { _env: 'URI' }, collection: 'users' },
184
+ });
185
+ writeDataSet(
186
+ 'alpha',
187
+ 'fixtures:\n computed-users: [{ _id: users-1 }]\ngenerate:\n seed: 1\n users:\n count: 1\n'
188
+ );
189
+ await expect(readDataSet({ configDirectory, buildDirectory, name: 'alpha' })).rejects.toThrow(
190
+ 'connections "users" and "computed-users" both load collection "users".'
191
+ );
161
192
  });
162
193
 
163
194
  test('readDataSet resolves generated connections and refuses one the app does not have', async () => {
@@ -0,0 +1,32 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { type } from '@lowdefy/helpers';
18
+
19
+ // The session database a database target (describeTargetKey) reads. Each target the build names has
20
+ // its own, numbered when the session opens; a target the build did not name (a config mutant's, say)
21
+ // gets the next number when first met, so it never merges with another. A target computed per
22
+ // request (null) reads the session's first database.
23
+ function sessionDatabaseName({ session, target }) {
24
+ if (type.isNone(target)) return session.databaseName;
25
+ if (!session.databases.has(target)) {
26
+ session.databases.set(target, `${session.prefix}_${session.databaseCount}`);
27
+ session.databaseCount += 1;
28
+ }
29
+ return session.databases.get(target);
30
+ }
31
+
32
+ export default sessionDatabaseName;
@@ -17,15 +17,18 @@
17
17
  import dataSessionRegistry from './dataSessionRegistry.js';
18
18
 
19
19
  // Drops every session database on the store that no registered session owns: one left behind by a
20
- // write that was still in flight when its session dropped the database, or by a dev server that
21
- // stopped mid-run.
20
+ // write that was still in flight when its session dropped its databases, or by a dev server that
21
+ // stopped mid-run. A session owns every database under its prefix (<prefix>_<n>), including one it
22
+ // numbers after this sweep lists them.
22
23
  async function sweepOrphanDatabases({ client }) {
23
- const owned = new Set([...dataSessionRegistry.values()].map((session) => session.databaseName));
24
+ const prefixes = [...dataSessionRegistry.values()].map((session) => `${session.prefix}_`);
24
25
  const { databases } = await client.db('admin').admin().listDatabases({ nameOnly: true });
25
26
  await Promise.all(
26
27
  databases
27
28
  .map((database) => database.name)
28
- .filter((name) => name.startsWith('ld_') && !owned.has(name))
29
+ .filter(
30
+ (name) => name.startsWith('ld_') && !prefixes.some((prefix) => name.startsWith(prefix))
31
+ )
29
32
  .map((name) => client.db(name).dropDatabase())
30
33
  );
31
34
  }
@@ -30,6 +30,15 @@ connections:
30
30
  _secret: MONGODB_URI
31
31
  collection: tickets
32
32
  write: true
33
+ # A second database that also has a tickets collection: under a data set it reads a session
34
+ # database of its own, so its fixtures never merge with the app's tickets.
35
+ - id: crm_tickets
36
+ type: MongoDBCollection
37
+ properties:
38
+ databaseUri:
39
+ _secret: CRM_MONGODB_URI
40
+ collection: tickets
41
+ write: true
33
42
 
34
43
  # A module connection is written under its module's folder (connections/archive/archive_db.json),
35
44
  # which the data set redirect must reach too.
@@ -81,6 +90,19 @@ api:
81
90
  _id: 1
82
91
  - :return:
83
92
  _step: find
93
+ - id: list_crm_tickets
94
+ type: Api
95
+ routine:
96
+ - id: find
97
+ type: MongoDBFind
98
+ connectionId: crm_tickets
99
+ properties:
100
+ query: {}
101
+ options:
102
+ sort:
103
+ _id: 1
104
+ - :return:
105
+ _step: find
84
106
  - id: create_ticket_later
85
107
  type: Api
86
108
  routine:
@@ -3,6 +3,10 @@ fixtures:
3
3
  - { _id: t-a1, organization_id: org_a, title: First }
4
4
  - { _id: t-a2, organization_id: org_a, title: Second }
5
5
  - { _id: t-b1, organization_id: org_b, title: Other org }
6
+ # The same collection name in another database: it loads into a session database of its own.
7
+ crm_tickets:
8
+ - { _id: t-a1, organization_id: org_a, title: CRM copy }
9
+ - { _id: crm-a2, organization_id: org_a, title: CRM only }
6
10
  users:
7
11
  owner: { id: u_1, roles: [admin], organizationId: org_a }
8
12
  outsider: { id: u_9, roles: [admin], organizationId: org_b }
@@ -30,9 +30,9 @@ const INSTRUCTIONS = `Lowdefy documentation and feedback server for this project
30
30
 
31
31
  Discovery workflow: start with lowdefy_overview. Use lowdefy_list_types with a kind to discover ALL installed blocks/operators/actions/connections/requests — never guess type names. Then lowdefy_get_schema and lowdefy_get_examples for the exact contract of a type, and lowdefy_get_doc / lowdefy_search_docs for concept documentation. Icons: use a semantic name (icon: edit); otherwise use a Lucide name in PascalCase (icon: Receipt) found with lowdefy_search_icons; never invent an icon name, never emoji; in any HTML string use <i data-icon="edit"></i>, data-tooltip="…" and data-popover="…", ClickableHtml with data-event for clicks (list each event in its dataEvents property; escape request and user data in the HTML), <a data-page-id="page" data-url-query="k=v"> for in-app links (never a hard-coded href; data-new-tab, not target), and data-tag="success" / data-status="success" for statuses (never inline-styled pills), <time datetime="…" data-time="relative">, data-format="currency" data-currency="USD" on raw numbers, data-avatar="Name" (never an avatar image service), data-copy, data-truncate="2", data-tone="secondary" (never inline grey colours) and, in ClickableHtml, confirm on destructive events in dataEvents ({ name: onDelete, confirm: "…" }); lowdefy_get_doc concepts/html-attributes lists them all. lowdefy_search_docs and lowdefy_get_doc also cover the docs this app's own plugins and modules ship (READMEs, docs/*.md, and modules/<id>/manifest for a module's components, exports and vars), so read those before reading plugin or module source.
32
32
 
33
- Push events: build results, server restarts and browser/server errors arrive as notifications/message from logger "lowdefy" (data.type is one of build, restart, client_error, server_error; a build event carries status, errors, warnings and stale). Act on them without polling — lowdefy_build_status remains the full picture.
33
+ Push events: build results, server restarts and browser/server errors arrive as notifications/message from logger "lowdefy" (data.type is one of build, restart, client_error, server_error; a build event carries status, errors, warnings and stale, for the config build only: pages build on request and send no event). Act on them without polling — lowdefy_build_status remains the full picture, and the only one that reports pages that fail to build.
34
34
 
35
- Feedback loop: after EVERY config edit, call lowdefy_build_status with wait: true — the dev server rebuilds on file change, wait: true answers once your edit has been processed and the pages it touched have been built, and this returns the current build errors/warnings (with source file locations), the pages that fail to build (pages.failed), recent browser runtime errors, and recent server errors (request, endpoint, MCP and agent failures with their config source). Errors reported before your latest edit are listed apart under earlierErrors, since they may already be fixed. Fix what it reports, then confirm the page builds with lowdefy_get_page_config, and visually verify with lowdefy_screenshot_page. Use lowdefy_find_config to locate where any id (page, block, request) is defined. lowdefy_scaffold_page creates a canonical new page file. Use lowdefy_app_map first to understand an existing app. If a tool result begins with "STALE:", the last build FAILED and the answer comes from the previous successful build, not from your latest edits — call lowdefy_build_status and fix the reported errors before trusting anything else.
35
+ Feedback loop: after EVERY config edit, call lowdefy_build_status with wait: true — the dev server rebuilds on file change, wait: true answers once your edit has been processed and the pages it touched have been built, and this returns build.status (ok when the config builds and no page failed its latest build; pages_failed, with failedPages naming them, when a page failed its latest build and has not changed since; error when the config build fails), the build errors/warnings and each failed page's errors (with source file locations), recent browser runtime errors, and recent server errors (request, endpoint, MCP and agent failures with their config source). Errors reported before your latest edit are listed apart under earlierErrors, since they may already be fixed. Fix what it reports, then confirm the page builds with lowdefy_get_page_config, and visually verify with lowdefy_screenshot_page. Use lowdefy_find_config to locate where any id (page, block, request) is defined. lowdefy_scaffold_page creates a canonical new page file. Use lowdefy_app_map first to understand an existing app. If a tool result begins with "STALE:", the last build FAILED and the answer comes from the previous successful build, not from your latest edits — call lowdefy_build_status and fix the reported errors before trusting anything else.
36
36
 
37
37
  Live state: lowdefy_inspect_state reads the ACTUAL state, request results, and event log of a running page — when the developer has the page open in their browser it reads THEIR live tab (ask them to interact, then inspect), otherwise it runs the page headless. lowdefy_eval_operator evaluates any operator expression against that live state — use it to debug _state/_request bindings. lowdefy_run_request executes a request with a test payload to verify data shape (read-only unless the app opts into writes). A request or endpoint response too large to return inline is written in full to a file under .lowdefy/responses/ and the result gives its path as responseFile; pass saveResponse: true to always write it there. lowdefy_run_endpoint runs an Api endpoint routine headlessly with a test payload (always needs cli.agentTools.allowWriteRequests, since routines are not classified read-only); a :reject comes back as status "reject" with the routine's own error, not as a tool failure. Pass system: true to run a scheduled or detached-only InternalApi routine as a system context (no _user, auth not checked), exactly as cron would.
38
38
 
@@ -231,7 +231,7 @@ const devToolDefinitions = {
231
231
 
232
232
  lowdefy_build_status: {
233
233
  description:
234
- 'Call after every config edit. Returns the current build status: errors and warnings from the last config build (with source file locations), the page builds (pages: failed lists the pages whose last build failed with their errors, changedSinceBuild the pages changed since they were built, unbuilt how many pages nothing has built since the dev server started), recent browser runtime errors, and recent server errors — request, endpoint, MCP and agent tool failures with their config source. Errors reported under an earlier build come apart under earlierErrors. The dev server rebuilds automatically on file change — edit, then call this with wait: true to see what broke.',
234
+ 'Call after every config edit. Returns the current build status: build.status is ok when the config build succeeds and no page failed its latest build, pages_failed (with build.failedPages naming the pages) when the config builds but a page failed its latest build and has not changed since, and error when the config build fails; build.configStatus is the status of the config build alone. A failed page changed since its last build is not counted until it is rebuilt: it is listed under pages.changedSinceBuild, and wait: true rebuilds it. Then the errors and warnings from the last config build (with source file locations), the page builds (pages: failed holds the errors of each failed page, changedSinceBuild the pages changed since they were built, unbuilt how many pages nothing has built since the dev server started), recent browser runtime errors, and recent server errors — request, endpoint, MCP and agent tool failures with their config source. Errors reported under an earlier build come apart under earlierErrors. The dev server rebuilds automatically on file change — edit, then call this with wait: true to see what broke.',
235
235
  inputSchema: {
236
236
  wait: z
237
237
  .boolean()
@@ -127,7 +127,7 @@ async function openPage({
127
127
  ]);
128
128
  }
129
129
  // A journey on a data set: every request from this context reads the data
130
- // session's database (see lib/server/applyDataSetRedirect.js), while the
130
+ // session's databases (see lib/server/applyDataSetRedirect.js), while the
131
131
  // developer's own tabs keep the app's real one. Set before the first
132
132
  // navigation, so no request from this context ever goes without it.
133
133
  if (!type.isUndefined(dataCookie)) {
@@ -32,6 +32,30 @@ function splitByBuild({ entries, buildId }) {
32
32
  };
33
33
  }
34
34
 
35
+ // buildStatus.json covers the config build only; pages build later, on first
36
+ // request. So that build.status alone answers "does the app build", an ok
37
+ // config build with a failed page reads pages_failed, and failedPages names
38
+ // those pages. A failed page changed since that build is left out: its failure
39
+ // may already be fixed, and pages.changedSinceBuild lists it until it is
40
+ // rebuilt. A config build that is not ok keeps its status and names no pages,
41
+ // since the config is what to fix first. The page errors stay under
42
+ // pages.failed either way. configStatus keeps the config build's own status for
43
+ // callers that wait on a published config build, whatever the pages do.
44
+ function withFailedPages({ build, pages }) {
45
+ const configBuild = { ...build, configStatus: build.status };
46
+ if (build.status !== 'ok') {
47
+ return configBuild;
48
+ }
49
+ const changedSinceBuild = new Set(pages.changedSinceBuild ?? []);
50
+ const failedPages = (pages.failed ?? [])
51
+ .map(({ pageId }) => pageId)
52
+ .filter((pageId) => !changedSinceBuild.has(pageId));
53
+ if (failedPages.length === 0) {
54
+ return configBuild;
55
+ }
56
+ return { ...configBuild, status: 'pages_failed', failedPages };
57
+ }
58
+
35
59
  // Notices about Lowdefy's agent block (see checkAgentFiles), checked on every
36
60
  // read: the root files are not watched, so a stored result would outlive a fix.
37
61
  async function getAgentFiles() {
@@ -43,7 +67,8 @@ async function getAgentFiles() {
43
67
  }
44
68
 
45
69
  // Feedback loop for agents: build status (written by the build manager to
46
- // build/buildStatus.json), the page builds (see getPageBuildStatus), plus
70
+ // build/buildStatus.json, with page failures folded in by withFailedPages),
71
+ // the page builds (see getPageBuildStatus), plus
47
72
  // recent browser errors reported via POST /api/client-error, plus recent server
48
73
  // errors (request, endpoint, MCP and agent tool failures) collected by
49
74
  // createHandleError. Lets an agent check "did my last edit work?" without
@@ -61,10 +86,11 @@ async function getBuildStatus({ checked } = {}) {
61
86
  const buildId = getBuildId();
62
87
  const clientErrors = splitByBuild({ entries: clientErrorStore.list(), buildId });
63
88
  const serverErrors = splitByBuild({ entries: serverErrorStore.list(), buildId });
89
+ const pages = await getPageBuildStatus({ checked });
64
90
  const status = {
65
91
  buildId,
66
- build,
67
- pages: await getPageBuildStatus({ checked }),
92
+ build: withFailedPages({ build, pages }),
93
+ pages,
68
94
  clientErrors: clientErrors.current,
69
95
  serverErrors: serverErrors.current,
70
96
  };
@@ -26,12 +26,108 @@ jest.unstable_mockModule('./serverErrorStore.js', () => ({ default: { list: mock
26
26
  jest.unstable_mockModule('./getBuildId.js', () => ({ default: () => 'build-2' }));
27
27
  const mockGetPageBuildStatus = jest.fn(() => ({ unbuilt: 0 }));
28
28
  jest.unstable_mockModule('./getPageBuildStatus.js', () => ({ default: mockGetPageBuildStatus }));
29
- jest.unstable_mockModule('./readBuildArtifact.js', () => ({
30
- default: () => ({ status: 'ok', errors: [], warnings: ['a build warning'] }),
31
- }));
29
+ const okBuild = () => ({ status: 'ok', errors: [], warnings: ['a build warning'] });
30
+ const mockReadBuildArtifact = jest.fn(okBuild);
31
+ jest.unstable_mockModule('./readBuildArtifact.js', () => ({ default: mockReadBuildArtifact }));
32
32
 
33
33
  const { default: getBuildStatus } = await import('./getBuildStatus.js');
34
34
 
35
+ describe('page failures in build.status', () => {
36
+ const failed = [
37
+ { pageId: 'orders', errors: [{ type: 'ConfigError', message: 'Bad.', source: null }] },
38
+ { pageId: 'invoices', errors: [{ type: 'ConfigError', message: 'Worse.', source: null }] },
39
+ ];
40
+
41
+ beforeEach(() => {
42
+ mockClientErrors.mockReturnValue([]);
43
+ mockServerErrors.mockReturnValue([]);
44
+ });
45
+
46
+ afterEach(() => {
47
+ mockReadBuildArtifact.mockImplementation(okBuild);
48
+ mockGetPageBuildStatus.mockImplementation(() => ({ unbuilt: 0 }));
49
+ });
50
+
51
+ test('reads pages_failed and names the pages when the config builds but a page fails', async () => {
52
+ mockGetPageBuildStatus.mockResolvedValue({ failed, unbuilt: 0 });
53
+
54
+ const status = await getBuildStatus();
55
+
56
+ expect(status.build).toEqual({
57
+ status: 'pages_failed',
58
+ configStatus: 'ok',
59
+ errors: [],
60
+ warnings: ['a build warning'],
61
+ failedPages: ['orders', 'invoices'],
62
+ });
63
+ expect(status.pages.failed).toEqual(failed);
64
+ });
65
+
66
+ test('keeps error and names no pages when the config build failed too', async () => {
67
+ mockReadBuildArtifact.mockReturnValue({
68
+ status: 'error',
69
+ errors: ['Bad config.'],
70
+ warnings: [],
71
+ });
72
+ mockGetPageBuildStatus.mockResolvedValue({ failed: failed.slice(0, 1), unbuilt: 0 });
73
+
74
+ const status = await getBuildStatus();
75
+
76
+ expect(status.build.status).toBe('error');
77
+ expect(status.build.configStatus).toBe('error');
78
+ expect(status.build).not.toHaveProperty('failedPages');
79
+ expect(status.pages.failed).toEqual(failed.slice(0, 1));
80
+ });
81
+
82
+ test('keeps unknown and names no pages before the first config build is written', async () => {
83
+ mockReadBuildArtifact.mockReturnValue(null);
84
+ mockGetPageBuildStatus.mockResolvedValue({ failed, unbuilt: 0 });
85
+
86
+ const { build } = await getBuildStatus();
87
+
88
+ expect(build.status).toBe('unknown');
89
+ expect(build.configStatus).toBe('unknown');
90
+ expect(build).not.toHaveProperty('failedPages');
91
+ });
92
+
93
+ test('leaves out a failed page changed since its last build, since its failure may be fixed', async () => {
94
+ mockGetPageBuildStatus.mockResolvedValue({
95
+ failed,
96
+ changedSinceBuild: ['orders'],
97
+ unbuilt: 0,
98
+ });
99
+
100
+ const { build } = await getBuildStatus();
101
+
102
+ expect(build.status).toBe('pages_failed');
103
+ expect(build.failedPages).toEqual(['invoices']);
104
+ });
105
+
106
+ test('stays ok when every failed page has changed since its last build', async () => {
107
+ mockGetPageBuildStatus.mockResolvedValue({
108
+ failed: failed.slice(0, 1),
109
+ changedSinceBuild: ['orders', 'home'],
110
+ unbuilt: 0,
111
+ });
112
+
113
+ const status = await getBuildStatus();
114
+
115
+ expect(status.build.status).toBe('ok');
116
+ expect(status.build).not.toHaveProperty('failedPages');
117
+ expect(status.pages.failed).toEqual(failed.slice(0, 1));
118
+ });
119
+
120
+ test('stays ok with no failedPages when every built page builds', async () => {
121
+ mockGetPageBuildStatus.mockResolvedValue({ checked: ['orders'], unbuilt: 2 });
122
+
123
+ const { build } = await getBuildStatus({ checked: ['orders'] });
124
+
125
+ expect(build.status).toBe('ok');
126
+ expect(build.configStatus).toBe('ok');
127
+ expect(build).not.toHaveProperty('failedPages');
128
+ });
129
+ });
130
+
35
131
  test('getBuildStatus lists errors from earlier builds apart from the current ones', async () => {
36
132
  mockClientErrors.mockReturnValue([
37
133
  { message: 'old render error', buildId: 'build-1' },
@@ -121,8 +121,8 @@ function getOverview() {
121
121
  '',
122
122
  'The dev server rebuilds automatically when config files change. After EVERY edit:',
123
123
  '',
124
- '1. **Check what broke**: `GET /lowdefy-docs/build-status?wait=true` — or `lowdefy_build_status` with `wait: true`, which answers once your latest edit has been processed and the pages it touched have been built. Current build errors and warnings with source file locations, the pages that fail to build (`pages.failed`), plus recent browser runtime errors. Errors from before the latest edit come apart under `earlierErrors`.',
125
- ' Or subscribe instead of polling: `GET /lowdefy-docs/events` streams `build`, `client_error`, `server_error` and `restart` events over SSE (MCP clients receive the same as `notifications/message` from logger `lowdefy`).',
124
+ '1. **Check what broke**: `GET /lowdefy-docs/build-status?wait=true` — or `lowdefy_build_status` with `wait: true`, which answers once your latest edit has been processed and the pages it touched have been built. `build.status` is `ok` when the config builds and no page failed its latest build: `pages_failed` (with `build.failedPages` naming the pages) when a page failed its latest build and has not changed since, `error` when the config build fails. Then the build errors and warnings and the errors of each failed page, with source file locations, plus recent browser runtime errors. Errors from before the latest edit come apart under `earlierErrors`.',
125
+ ' Or subscribe instead of polling: `GET /lowdefy-docs/events` streams `build`, `client_error`, `server_error` and `restart` events over SSE (MCP clients receive the same as `notifications/message` from logger `lowdefy`). A `build` event covers the config build only, so check build status for pages that fail to build.',
126
126
  '2. **Verify the page**: `GET /lowdefy-docs/page-config/{pageId}` — or `lowdefy_get_page_config`. The fully built page config, or its structured build errors.',
127
127
  "3. **See it rendered**: `GET /lowdefy-docs/screenshot/{pageId}` — or `lowdefy_screenshot_page`. PNG of the page in headless Chromium; `?viewportWidth=390&colorScheme=dark` (the tool's `width` and `colorScheme`) checks a phone layout in dark mode.",
128
128
  '4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery, state}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `back`, `goto`, `email`, `as`, `wait`, `screenshot`, `expect: { state | visible | text | url | title }`) and assert what happens. `user: "none"` injects no caller, so sign-up, sign-in and invitations run through the app\'s own auth. Targets are a blockId, or `{ blockId, row, column, text, nth }` to reach the button in a grid cell, a control by its exact text, or — with `text` alone — a confirm dialog / modal footer button rendered outside every block. A failing step stops the journey and returns `passed: false` with `failure: { index, step, expected, actual, message }` and the final page `state` — data, not an error. A final state over 10000 characters comes back as `stateOmitted` (its size and top-level keys); pass `state: [paths]` for the paths you need, `true` for all of it or `false` for none.',
@@ -20,9 +20,9 @@ import pageBlocks from '../pageBlocks.js';
20
20
  import readBuiltArtifact from './readBuiltArtifact.js';
21
21
  import readRoutineSteps from '../readRoutineSteps.js';
22
22
 
23
- // The meta.dataSet declarations a data session runs against the session's database (see
24
- // lib/server/applyDataSetRedirect.js): 'redirect' takes the session database, 'walled' reaches it
25
- // only through walled connections. Every other declaration, or none, would reach a real service or
23
+ // The meta.dataSet declarations a data session runs against the session's databases (see
24
+ // lib/server/applyDataSetRedirect.js): 'redirect' takes its target's session database, 'walled'
25
+ // reaches them only through walled connections. Every other declaration, or none, would reach a real service or
26
26
  // is refused by the gate.
27
27
  const CLICKABLE_DATA_SET_MODES = new Set(['redirect', 'walled']);
28
28
 
@@ -59,7 +59,7 @@ const dataSet = {
59
59
  fixtures: { tickets: [{ _id: 't1' }, { _id: 't2' }], companies: [{ _id: 'c1' }] },
60
60
  generated: { invoices: [{ _id: 'i1' }] },
61
61
  users: { outsider: { id: 'u_9', roles: ['admin'], organizationId: 'org_b' } },
62
- warnings: ['Connections "a" and "b" both name collection "events" in different databases.'],
62
+ warnings: ['Data set "sample" loads 1,200 documents for connection "tickets".'],
63
63
  };
64
64
 
65
65
  let order;
@@ -19,8 +19,8 @@ import { type } from '@lowdefy/helpers';
19
19
  import getDataStore from './dataSets/getDataStore.js';
20
20
  import openDataSession from './dataSets/openDataSession.js';
21
21
 
22
- // Runs `task` on a data set's own database when the call named one: a fresh
23
- // database on the dev server's memory store, loaded with the data set and
22
+ // Runs `task` on a data set's own databases when the call named one: fresh
23
+ // databases on the dev server's memory store, loaded with the data set and
24
24
  // dropped once the task settles, exactly as a journey's. The task gets
25
25
  // `dataCookie` (for a headless browser context, see openPage) and
26
26
  // `dataSession` (for a context the dev server builds itself, see
@@ -17,6 +17,8 @@
17
17
  import { mergeObjects, type } from '@lowdefy/helpers';
18
18
 
19
19
  import createDataSetRefusal from './createDataSetRefusal.js';
20
+ import describeTargetKey from '../docs/dataSets/describeTargetKey.js';
21
+ import sessionDatabaseName from '../docs/dataSets/sessionDatabaseName.js';
20
22
 
21
23
  // Module connections are written under their module's folder (connections/<module>/<id>.json), so
22
24
  // the pattern matches every depth.
@@ -29,13 +31,16 @@ function hasLiteralKeys(properties) {
29
31
  }
30
32
 
31
33
  // The one gate for connections under a data session, for this request only. It fails closed: a
32
- // connection is either verifiably pointed at the session's database or the read throws. A type
34
+ // connection is either verifiably pointed at a session database or the read throws. A type
33
35
  // declares how it behaves through meta.dataSet on its plugin, in one of three modes:
34
- // - 'redirect' takes its database from databaseUri and databaseName, so the session's are merged
35
- // over its properties whether or not the data set lists it.
36
+ // - 'redirect' takes its database from databaseUri and databaseName, so the session's URI and the
37
+ // session database of its own target (its databaseUri secret or literal and databaseName) are
38
+ // merged over its properties whether or not the data set lists it. Connections on different
39
+ // targets read different databases, as they do outside the session; one whose target is computed
40
+ // per request reads the session's first database.
36
41
  // - 'walled' holds no database address and reaches MongoDB only through the walled client (the
37
42
  // resolver's walled(connectionId) argument), so it passes unchanged: the walled connections it
38
- // names are read through this same gate and take the session database as redirect connections.
43
+ // names are read through this same gate and take their session databases as redirect connections.
39
44
  // - 'external' reaches an outside service and keeps its target.
40
45
  // Anything else, including no declaration, refuses. The gate cannot verify a walled or external
41
46
  // declaration, since properties are evaluated later and may read secrets: it is the plugin
@@ -70,11 +75,15 @@ function applyDataSetRedirect({ context, session, connections }) {
70
75
  reason: 'its properties are an operator, so the redirect cannot be checked',
71
76
  });
72
77
  }
78
+ const target = describeTargetKey({ properties: artifact.properties });
73
79
  return {
74
80
  ...artifact,
75
81
  properties: mergeObjects([
76
82
  artifact.properties,
77
- { databaseUri: session.databaseUri, databaseName: session.databaseName },
83
+ {
84
+ databaseUri: session.databaseUri,
85
+ databaseName: sessionDatabaseName({ session, target }),
86
+ },
78
87
  ]),
79
88
  };
80
89
  };