@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.
- package/lib/docs/dataSets/checkSharedCollectionIds.js +20 -9
- package/lib/docs/dataSets/dataSetJourneys.chromium.mongodb.test.mjs +53 -5
- package/lib/docs/dataSets/describeTargetKey.js +29 -0
- package/lib/docs/dataSets/groupDataSetByCollection.js +19 -11
- package/lib/docs/dataSets/openDataSession.js +32 -18
- package/lib/docs/dataSets/openDataSession.mongodb.test.mjs +150 -0
- package/lib/docs/dataSets/readDataSet.js +34 -19
- package/lib/docs/dataSets/readDataSet.test.mjs +56 -25
- package/lib/docs/dataSets/sessionDatabaseName.js +32 -0
- package/lib/docs/dataSets/sweepOrphanDatabases.js +7 -4
- package/lib/docs/dataSets/test/app/lowdefy.yaml +22 -0
- package/lib/docs/dataSets/test/app/tests/data/shop.yaml +4 -0
- package/lib/docs/devToolDefinitions.js +3 -3
- package/lib/docs/getBrowser.js +1 -1
- package/lib/docs/getBuildStatus.js +29 -3
- package/lib/docs/getBuildStatus.test.mjs +99 -3
- package/lib/docs/getOverview.js +2 -2
- package/lib/docs/rails/findExternalBlocks.js +3 -3
- package/lib/docs/runJourneyDataSet.test.mjs +1 -1
- package/lib/docs/withDataSession.js +2 -2
- package/lib/server/applyDataSetRedirect.js +14 -5
- package/lib/server/applyDataSetRedirect.test.mjs +119 -13
- package/lib/server/applyDataSetRedirectTenantReach.test.mjs +4 -1
- package/lib/server/applyDataSetRedirectWalled.test.mjs +4 -1
- package/lib/server/createLowdefyContext.js +3 -3
- package/lib/server/createLowdefyContext.test.mjs +6 -3
- package/lib/server/mutantDataSetWrappers.test.mjs +4 -1
- package/manager/getContext.mjs +2 -2
- package/manager/processes/initialBuild.mjs +3 -1
- package/manager/processes/initialBuild.test.mjs +3 -0
- package/manager/processes/lowdefyBuild.mjs +4 -3
- package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +10 -0
- package/package.json +37 -37
- package/package.original.json +37 -37
- 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
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
writeConnection('
|
|
153
|
-
writeConnection('
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
159
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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()
|
package/lib/docs/getBrowser.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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' },
|
package/lib/docs/getOverview.js
CHANGED
|
@@ -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.
|
|
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
|
|
24
|
-
// lib/server/applyDataSetRedirect.js): 'redirect' takes
|
|
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: ['
|
|
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
|
|
23
|
-
//
|
|
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
|
|
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
|
|
35
|
-
//
|
|
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
|
|
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
|
-
{
|
|
83
|
+
{
|
|
84
|
+
databaseUri: session.databaseUri,
|
|
85
|
+
databaseName: sessionDatabaseName({ session, target }),
|
|
86
|
+
},
|
|
78
87
|
]),
|
|
79
88
|
};
|
|
80
89
|
};
|