wizards 0.2.11 → 0.2.13

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 (86) hide show
  1. package/apps/runtime/dist/authoring/guide.js +8 -2
  2. package/apps/runtime/dist/db/migrations/tenant/0007_space_data.sql +43 -0
  3. package/apps/runtime/dist/db/migrations/tenant/meta/0007_snapshot.json +2012 -0
  4. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  5. package/apps/runtime/dist/db/schema.js +55 -0
  6. package/apps/runtime/dist/engine/runner.js +1 -1
  7. package/apps/runtime/dist/routes/api.js +14 -1
  8. package/apps/runtime/dist/routes/runs.js +13 -7
  9. package/apps/runtime/dist/routes/studio.js +89 -1
  10. package/apps/runtime/dist/services/cloud.js +62 -2
  11. package/apps/runtime/dist/services/runs.js +69 -1
  12. package/apps/runtime/dist/services/space-data.js +361 -0
  13. package/apps/runtime/dist/services/spaces.js +95 -1
  14. package/apps/runtime/dist/store/errors.js +3 -0
  15. package/apps/runtime/dist/store/index.js +19 -5
  16. package/apps/runtime/dist/store/shared-lists.js +124 -0
  17. package/apps/runtime/dist/tools/index.js +4 -0
  18. package/apps/runtime/dist/tools/pages.js +51 -0
  19. package/apps/runtime/dist/tools/store.js +3 -3
  20. package/apps/web/dist/assets/Account-ulB5WeOT.js +1 -0
  21. package/apps/web/dist/assets/ChangelogDialog-BVR5814e.js +1 -0
  22. package/apps/web/dist/assets/ChatPanel-C7uql4_d.js +1 -0
  23. package/apps/web/dist/assets/{CreditsDialog-DSjsgLoo.js → CreditsDialog-o9h0Nrui.js} +1 -1
  24. package/apps/web/dist/assets/EditorPage-CskikVRK.js +2 -0
  25. package/apps/web/dist/assets/FlowPreview-YaqUirty.js +1 -0
  26. package/apps/web/dist/assets/{Harness-OlRaoHGd.js → Harness-ut5xGR_o.js} +2 -2
  27. package/apps/web/dist/assets/HomePage-Drys3BPU.js +1 -0
  28. package/apps/web/dist/assets/{ImportWizard-BGs5dv94.js → ImportWizard-BGqkJFeZ.js} +1 -1
  29. package/apps/web/dist/assets/LocalRuntime-DwPJjv3H.js +1 -0
  30. package/apps/web/dist/assets/NewWizardPage-CrBSpeIw.js +2 -0
  31. package/apps/web/dist/assets/ProjectPage-CAdSHd0Y.js +4 -0
  32. package/apps/web/dist/assets/PublicRunner-JleX2stQ.js +1 -0
  33. package/apps/web/dist/assets/RunnerView-BWG4Odbt.js +70 -0
  34. package/apps/web/dist/assets/SettingsPage-CYOdJ5FL.js +1 -0
  35. package/apps/web/dist/assets/SetupPage-CMVwnLQm.js +1 -0
  36. package/apps/web/dist/assets/SharePage-CKppWAtr.js +1 -0
  37. package/apps/web/dist/assets/{api-u3nydnnp.js → api-DTLkC0ge.js} +2 -2
  38. package/apps/web/dist/assets/changelog-dI1t4l0T.js +1 -0
  39. package/apps/web/dist/assets/circle-alert-D2SCwFT-.js +1 -0
  40. package/apps/web/dist/assets/{definition-Co2Mg1qo.js → definition-Dp0DR2D6.js} +1 -1
  41. package/apps/web/dist/assets/{esm-CyCiCaFy.js → esm-B6hLaDRL.js} +1 -1
  42. package/apps/web/dist/assets/index-BWxUOTc4.js +10 -0
  43. package/apps/web/dist/assets/index-DMwk_afK.css +1 -0
  44. package/apps/web/dist/assets/lock-DZL2N67v.js +1 -0
  45. package/apps/web/dist/assets/meta-CZWUthoQ.js +1 -0
  46. package/apps/web/dist/assets/refresh-cw-BNtHis1x.js +1 -0
  47. package/apps/web/dist/assets/settings-menu-Iz6kyoDe.js +1 -0
  48. package/apps/web/dist/assets/{type-BqbppSsp.js → table-2-BdDuBcF2.js} +1 -1
  49. package/apps/web/dist/assets/ui-CHX2gEQl.js +1 -0
  50. package/apps/web/dist/assets/video-9fDPRPCv.js +1 -0
  51. package/apps/web/dist/index.html +4 -4
  52. package/apps/web/dist/mcp/flow.html +5 -5
  53. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +12 -4
  54. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +6 -1
  55. package/node_modules/@engenty-wizards/shared/dist/definition.js +36 -2
  56. package/node_modules/@engenty-wizards/shared/dist/space-data.d.ts +69 -0
  57. package/node_modules/@engenty-wizards/shared/dist/space-data.js +46 -0
  58. package/node_modules/@engenty-wizards/shared/dist/store.d.ts +2 -0
  59. package/package.json +1 -1
  60. package/apps/web/dist/assets/Account-BJPPu___.js +0 -1
  61. package/apps/web/dist/assets/ChangelogDialog-D5Jc3poI.js +0 -1
  62. package/apps/web/dist/assets/ChatPanel-n7GEZxTX.js +0 -1
  63. package/apps/web/dist/assets/EditorPage-teTTDbnX.js +0 -2
  64. package/apps/web/dist/assets/FlowPreview-4cRsWwO8.js +0 -1
  65. package/apps/web/dist/assets/HomePage-BGQMVqEr.js +0 -1
  66. package/apps/web/dist/assets/LocalRuntime-WHeeZvB4.js +0 -1
  67. package/apps/web/dist/assets/NewWizardPage-h3dWMUgw.js +0 -2
  68. package/apps/web/dist/assets/ProjectPage-Cfnwi-pK.js +0 -2
  69. package/apps/web/dist/assets/PublicRunner-Oa4uTkvr.js +0 -1
  70. package/apps/web/dist/assets/ReadOnly-B0-ReU_0.js +0 -1
  71. package/apps/web/dist/assets/RunnerView-njFeqF1A.js +0 -2
  72. package/apps/web/dist/assets/SettingsPage-D4xJ8tEu.js +0 -1
  73. package/apps/web/dist/assets/SetupPage-DUyvWSdi.js +0 -1
  74. package/apps/web/dist/assets/SharePage-Bikt8bn4.js +0 -1
  75. package/apps/web/dist/assets/arrow-left-hTN1VoH1.js +0 -1
  76. package/apps/web/dist/assets/changelog-CT8yK95a.js +0 -1
  77. package/apps/web/dist/assets/circle-alert-B76z9TWZ.js +0 -1
  78. package/apps/web/dist/assets/index-CQQD8QNR.css +0 -1
  79. package/apps/web/dist/assets/index-Z0fbfnW7.js +0 -10
  80. package/apps/web/dist/assets/lock-CzNXAYdi.js +0 -1
  81. package/apps/web/dist/assets/meta-DsCJykuf.js +0 -1
  82. package/apps/web/dist/assets/mic-Bvz4mA2j.js +0 -1
  83. package/apps/web/dist/assets/outputs-Ba-Tblsj.js +0 -69
  84. package/apps/web/dist/assets/refresh-cw-Cmk7VCe0.js +0 -1
  85. package/apps/web/dist/assets/ui-gGAdXx-P.js +0 -1
  86. package/apps/web/dist/assets/video-DBmwF1Jm.js +0 -1
@@ -0,0 +1,361 @@
1
+ import { TableColumnValueError, tableColumnsSchema, } from "@engenty-wizards/shared/engenty/data-tables";
2
+ import { editCells, SPACE_DATA_LIMITS, } from "@engenty-wizards/shared/space-data";
3
+ import { rowKey } from "@engenty-wizards/shared/store";
4
+ import { and, asc, count, desc, eq, inArray } from "drizzle-orm";
5
+ import { nanoid } from "nanoid";
6
+ import { db, schema } from "../db/client.js";
7
+ import { currentTenant } from "../tenants/tenant.js";
8
+ import { projectWritable, requireWritableProject } from "./access.js";
9
+ import { notFound, ServiceError } from "./errors.js";
10
+ import { ownedProject } from "./projects.js";
11
+ /** A wizard of the space, or null for the space's own. */
12
+ async function ownerOf(projectId, wizardId) {
13
+ if (!wizardId) {
14
+ return null;
15
+ }
16
+ const w = await db.query.wizard.findFirst({
17
+ where: and(eq(schema.wizard.id, wizardId), eq(schema.wizard.projectId, projectId)),
18
+ columns: { id: true },
19
+ });
20
+ if (!w) {
21
+ throw new ServiceError("invalid", "Diesen Wizard gibt es im Space nicht.");
22
+ }
23
+ return w.id;
24
+ }
25
+ function cellError(err) {
26
+ if (err instanceof TableColumnValueError) {
27
+ throw new ServiceError("invalid", err.message, { column: err.columnId });
28
+ }
29
+ throw err;
30
+ }
31
+ function checkColumns(columns) {
32
+ const parsed = tableColumnsSchema.safeParse(columns);
33
+ if (!parsed.success) {
34
+ throw new ServiceError("invalid", parsed.error.issues[0]?.message ?? "Ungültige Spalten.");
35
+ }
36
+ return parsed.data;
37
+ }
38
+ export async function listSpaceData(userId, projectId) {
39
+ const project = await ownedProject(userId, projectId);
40
+ const [tables, rows, pages, wizards] = await Promise.all([
41
+ db.query.spaceTable.findMany({
42
+ where: eq(schema.spaceTable.projectId, project.id),
43
+ orderBy: [asc(schema.spaceTable.title)],
44
+ columns: { id: true, wizardId: true, list: true, title: true, updatedAt: true },
45
+ }),
46
+ db
47
+ .select({ tableId: schema.spaceTableRow.tableId, n: count() })
48
+ .from(schema.spaceTableRow)
49
+ .innerJoin(schema.spaceTable, eq(schema.spaceTable.id, schema.spaceTableRow.tableId))
50
+ .where(eq(schema.spaceTable.projectId, project.id))
51
+ .groupBy(schema.spaceTableRow.tableId),
52
+ db.query.spacePage.findMany({
53
+ where: eq(schema.spacePage.projectId, project.id),
54
+ orderBy: [asc(schema.spacePage.title)],
55
+ columns: { id: true, wizardId: true, title: true, updatedAt: true },
56
+ }),
57
+ db.query.wizard.findMany({
58
+ where: eq(schema.wizard.projectId, project.id),
59
+ orderBy: [desc(schema.wizard.updatedAt)],
60
+ columns: { id: true, title: true },
61
+ }),
62
+ ]);
63
+ return {
64
+ tables: tables.map((t) => ({
65
+ ...t,
66
+ rows: rows.find((r) => r.tableId === t.id)?.n ?? 0,
67
+ updatedAt: t.updatedAt.toISOString(),
68
+ })),
69
+ pages: pages.map((p) => ({ ...p, updatedAt: p.updatedAt.toISOString() })),
70
+ wizards,
71
+ };
72
+ }
73
+ // --- tables --------------------------------------------------------------------
74
+ async function tableRow(tableId) {
75
+ const table = await db.query.spaceTable.findFirst({
76
+ where: and(eq(schema.spaceTable.id, tableId), eq(schema.spaceTable.tenantId, currentTenant())),
77
+ });
78
+ if (!table) {
79
+ throw notFound();
80
+ }
81
+ return table;
82
+ }
83
+ async function writableTable(tableId) {
84
+ const table = await tableRow(tableId);
85
+ await requireWritableProject(table.projectId);
86
+ return table;
87
+ }
88
+ async function touchTable(tableId) {
89
+ await db
90
+ .update(schema.spaceTable)
91
+ .set({ updatedAt: new Date() })
92
+ .where(eq(schema.spaceTable.id, tableId));
93
+ }
94
+ export async function createTable(userId, projectId, input) {
95
+ const project = await ownedProject(userId, projectId);
96
+ await requireWritableProject(project.id);
97
+ const [{ n }] = await db
98
+ .select({ n: count() })
99
+ .from(schema.spaceTable)
100
+ .where(eq(schema.spaceTable.projectId, project.id));
101
+ if (n >= SPACE_DATA_LIMITS.tables) {
102
+ throw new ServiceError("refused", `Ein Space hat höchstens ${SPACE_DATA_LIMITS.tables} Tabellen.`);
103
+ }
104
+ const columns = input.columns
105
+ ? checkColumns(input.columns)
106
+ : [{ id: "name", name: "Name", type: "text" }];
107
+ const id = nanoid(12);
108
+ await db.insert(schema.spaceTable).values({
109
+ id,
110
+ tenantId: currentTenant(),
111
+ projectId: project.id,
112
+ wizardId: await ownerOf(project.id, input.wizardId),
113
+ title: input.title.trim() || "Tabelle",
114
+ columns,
115
+ });
116
+ return { id };
117
+ }
118
+ export async function getTable(userId, tableId) {
119
+ const table = await tableRow(tableId);
120
+ const project = await ownedProject(userId, table.projectId);
121
+ const rows = await db.query.spaceTableRow.findMany({
122
+ where: eq(schema.spaceTableRow.tableId, table.id),
123
+ orderBy: [asc(schema.spaceTableRow.createdAt), asc(schema.spaceTableRow.id)],
124
+ });
125
+ return {
126
+ id: table.id,
127
+ projectId: table.projectId,
128
+ wizardId: table.wizardId,
129
+ list: table.list,
130
+ title: table.title,
131
+ columns: table.columns,
132
+ rows: rows.map((r) => ({ id: r.id, cells: r.cells, updatedAt: r.updatedAt.toISOString() })),
133
+ readOnly: !(await projectWritable(project)),
134
+ };
135
+ }
136
+ /**
137
+ * A new title, owner or columns. Rows keep their cells: a removed column's cells are dropped
138
+ * the next time the row changes, and are not shown before.
139
+ */
140
+ export async function updateTable(_userId, tableId, patch) {
141
+ const table = await writableTable(tableId);
142
+ if (table.list && (patch.columns !== undefined || patch.wizardId !== undefined)) {
143
+ throw new ServiceError("refused", "Diese Tabelle gehört zu einer Liste des Wizards: ihre Spalten kommen aus dem Wizard.");
144
+ }
145
+ await db
146
+ .update(schema.spaceTable)
147
+ .set({
148
+ ...(patch.title !== undefined ? { title: patch.title.trim() || table.title } : {}),
149
+ ...(patch.wizardId !== undefined
150
+ ? { wizardId: await ownerOf(table.projectId, patch.wizardId) }
151
+ : {}),
152
+ ...(patch.columns !== undefined ? { columns: checkColumns(patch.columns) } : {}),
153
+ updatedAt: new Date(),
154
+ })
155
+ .where(eq(schema.spaceTable.id, table.id));
156
+ }
157
+ export async function deleteTable(_userId, tableId) {
158
+ const table = await writableTable(tableId);
159
+ await db.delete(schema.spaceTable).where(eq(schema.spaceTable.id, table.id));
160
+ }
161
+ /** The key a row of a shared list is matched on; tables made in the studio have none. */
162
+ function keyOf(table, cells) {
163
+ return table.keyColumn
164
+ ? rowKey({ id: table.list ?? "", title: table.title, columns: table.columns, key: table.keyColumn }, cells)
165
+ : null;
166
+ }
167
+ /** A row of a shared list whose key another row has already is refused: runs match on it. */
168
+ async function refuseClash(tableId, key, rowId) {
169
+ if (!key) {
170
+ return;
171
+ }
172
+ const other = await db.query.spaceTableRow.findFirst({
173
+ where: and(eq(schema.spaceTableRow.tableId, tableId), eq(schema.spaceTableRow.key, key)),
174
+ columns: { id: true },
175
+ });
176
+ if (other && other.id !== rowId) {
177
+ throw new ServiceError("invalid", "Diesen Eintrag gibt es schon.");
178
+ }
179
+ }
180
+ /** Adds a row; `cells` may leave columns empty, even required ones, to be filled later. */
181
+ export async function addTableRow(_userId, tableId, cells = {}) {
182
+ const table = await writableTable(tableId);
183
+ const [{ n }] = await db
184
+ .select({ n: count() })
185
+ .from(schema.spaceTableRow)
186
+ .where(eq(schema.spaceTableRow.tableId, table.id));
187
+ if (n >= SPACE_DATA_LIMITS.rowsPerTable) {
188
+ throw new ServiceError("refused", `Eine Tabelle hat höchstens ${SPACE_DATA_LIMITS.rowsPerTable} Zeilen.`);
189
+ }
190
+ let value;
191
+ try {
192
+ value = editCells(table.columns, {}, cells);
193
+ }
194
+ catch (err) {
195
+ cellError(err);
196
+ }
197
+ const key = keyOf(table, value);
198
+ await refuseClash(table.id, key, null);
199
+ const row = { id: nanoid(14), tableId: table.id, key, cells: value, createdAt: new Date() };
200
+ await db.insert(schema.spaceTableRow).values(row);
201
+ await touchTable(table.id);
202
+ return { id: row.id, cells: value };
203
+ }
204
+ /** Changes the cells named in `cells`; the others stay. */
205
+ export async function updateTableRow(_userId, tableId, rowId, cells) {
206
+ const table = await writableTable(tableId);
207
+ const row = await db.query.spaceTableRow.findFirst({
208
+ where: and(eq(schema.spaceTableRow.id, rowId), eq(schema.spaceTableRow.tableId, table.id)),
209
+ });
210
+ if (!row) {
211
+ throw notFound();
212
+ }
213
+ let value;
214
+ try {
215
+ value = editCells(table.columns, row.cells, cells);
216
+ }
217
+ catch (err) {
218
+ cellError(err);
219
+ }
220
+ const key = keyOf(table, value);
221
+ await refuseClash(table.id, key, row.id);
222
+ await db
223
+ .update(schema.spaceTableRow)
224
+ .set({ cells: value, key, updatedAt: new Date() })
225
+ .where(eq(schema.spaceTableRow.id, row.id));
226
+ await touchTable(table.id);
227
+ return { id: row.id, cells: value };
228
+ }
229
+ export async function deleteTableRows(_userId, tableId, rowIds) {
230
+ const table = await writableTable(tableId);
231
+ if (!rowIds.length) {
232
+ return;
233
+ }
234
+ await db
235
+ .delete(schema.spaceTableRow)
236
+ .where(and(eq(schema.spaceTableRow.tableId, table.id), inArray(schema.spaceTableRow.id, rowIds)));
237
+ await touchTable(table.id);
238
+ }
239
+ // --- pages ---------------------------------------------------------------------
240
+ async function pageRow(pageId) {
241
+ const page = await db.query.spacePage.findFirst({
242
+ where: and(eq(schema.spacePage.id, pageId), eq(schema.spacePage.tenantId, currentTenant())),
243
+ });
244
+ if (!page) {
245
+ throw notFound();
246
+ }
247
+ return page;
248
+ }
249
+ function checkMarkdown(markdown) {
250
+ if (markdown.length > SPACE_DATA_LIMITS.pageChars) {
251
+ throw new ServiceError("refused", `Eine Seite hat höchstens ${SPACE_DATA_LIMITS.pageChars.toLocaleString("de")} Zeichen.`);
252
+ }
253
+ return markdown;
254
+ }
255
+ export async function createPage(userId, projectId, input) {
256
+ const project = await ownedProject(userId, projectId);
257
+ await requireWritableProject(project.id);
258
+ const [{ n }] = await db
259
+ .select({ n: count() })
260
+ .from(schema.spacePage)
261
+ .where(eq(schema.spacePage.projectId, project.id));
262
+ if (n >= SPACE_DATA_LIMITS.pages) {
263
+ throw new ServiceError("refused", `Ein Space hat höchstens ${SPACE_DATA_LIMITS.pages} Seiten.`);
264
+ }
265
+ const id = nanoid(12);
266
+ await db.insert(schema.spacePage).values({
267
+ id,
268
+ tenantId: currentTenant(),
269
+ projectId: project.id,
270
+ wizardId: await ownerOf(project.id, input.wizardId),
271
+ title: input.title.trim() || "Seite",
272
+ markdown: checkMarkdown(input.markdown ?? ""),
273
+ });
274
+ return { id };
275
+ }
276
+ export async function getPage(userId, pageId) {
277
+ const page = await pageRow(pageId);
278
+ const project = await ownedProject(userId, page.projectId);
279
+ return {
280
+ id: page.id,
281
+ projectId: page.projectId,
282
+ wizardId: page.wizardId,
283
+ title: page.title,
284
+ markdown: page.markdown,
285
+ updatedAt: page.updatedAt.toISOString(),
286
+ readOnly: !(await projectWritable(project)),
287
+ };
288
+ }
289
+ export async function updatePage(_userId, pageId, patch) {
290
+ const page = await pageRow(pageId);
291
+ await requireWritableProject(page.projectId);
292
+ await db
293
+ .update(schema.spacePage)
294
+ .set({
295
+ ...(patch.title !== undefined ? { title: patch.title.trim() || page.title } : {}),
296
+ ...(patch.wizardId !== undefined
297
+ ? { wizardId: await ownerOf(page.projectId, patch.wizardId) }
298
+ : {}),
299
+ ...(patch.markdown !== undefined ? { markdown: checkMarkdown(patch.markdown) } : {}),
300
+ updatedAt: new Date(),
301
+ })
302
+ .where(eq(schema.spacePage.id, page.id));
303
+ }
304
+ export async function deletePage(_userId, pageId) {
305
+ const page = await pageRow(pageId);
306
+ await requireWritableProject(page.projectId);
307
+ await db.delete(schema.spacePage).where(eq(schema.spacePage.id, page.id));
308
+ }
309
+ // --- what runs read and write --------------------------------------------------
310
+ // A run writes the pages of its own wizard, on the runtime it runs on: a run on the server writes
311
+ // the server's, also in a space a local install synced, which the studio there only shows. It
312
+ // reads its wizard's pages and the space's own.
313
+ /** The pages a run can read: its wizard's, then the space's own. */
314
+ export async function runPages(projectId, wizardId) {
315
+ const pages = await db.query.spacePage.findMany({
316
+ where: eq(schema.spacePage.projectId, projectId),
317
+ columns: { id: true, wizardId: true, title: true, markdown: true, updatedAt: true },
318
+ orderBy: [asc(schema.spacePage.title)],
319
+ });
320
+ return pages
321
+ .filter((p) => p.wizardId === wizardId || p.wizardId === null)
322
+ .sort((a, b) => Number(b.wizardId === wizardId) - Number(a.wizardId === wizardId));
323
+ }
324
+ /** Writes a page of the run's wizard: a new one, or the one with this title, replaced or added to. */
325
+ export async function writeRunPage(projectId, wizardId, input) {
326
+ const title = input.title.trim();
327
+ if (!title) {
328
+ throw new ServiceError("invalid", "A page needs a title.");
329
+ }
330
+ const existing = (await db.query.spacePage.findMany({
331
+ where: and(eq(schema.spacePage.projectId, projectId), eq(schema.spacePage.wizardId, wizardId)),
332
+ })).find((p) => p.title.toLowerCase() === title.toLowerCase());
333
+ const markdown = existing && input.append
334
+ ? `${existing.markdown.trimEnd()}\n\n${input.markdown.trim()}`
335
+ : input.markdown;
336
+ checkMarkdown(markdown);
337
+ if (existing) {
338
+ await db
339
+ .update(schema.spacePage)
340
+ .set({ markdown, updatedAt: new Date() })
341
+ .where(eq(schema.spacePage.id, existing.id));
342
+ return { id: existing.id, title: existing.title, chars: markdown.length, made: false };
343
+ }
344
+ const [{ n }] = await db
345
+ .select({ n: count() })
346
+ .from(schema.spacePage)
347
+ .where(eq(schema.spacePage.projectId, projectId));
348
+ if (n >= SPACE_DATA_LIMITS.pages) {
349
+ throw new ServiceError("refused", `A space keeps at most ${SPACE_DATA_LIMITS.pages} pages.`);
350
+ }
351
+ const id = nanoid(12);
352
+ await db.insert(schema.spacePage).values({
353
+ id,
354
+ tenantId: currentTenant(),
355
+ projectId,
356
+ wizardId,
357
+ title: title.slice(0, 200),
358
+ markdown,
359
+ });
360
+ return { id, title, chars: markdown.length, made: true };
361
+ }
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { tableColumnsSchema } from "@engenty-wizards/shared/engenty/data-tables";
2
3
  import { PROJECT_LIMITS } from "@engenty-wizards/shared/projects";
4
+ import { SPACE_DATA_LIMITS } from "@engenty-wizards/shared/space-data";
3
5
  import { WORKSPACE_LIMITS } from "@engenty-wizards/shared/workspace";
4
- import { and, count, eq, inArray, sql } from "drizzle-orm";
6
+ import { and, count, eq, inArray, isNull, notInArray, sql } from "drizzle-orm";
5
7
  import { nanoid } from "nanoid";
6
8
  import { z } from "zod";
7
9
  import { db, inTransaction, schema } from "../db/client.js";
@@ -406,3 +408,95 @@ export async function removeSyncedSpace(rawSpaceId) {
406
408
  await asSync(() => removeProject(spaceId));
407
409
  return { ok: true };
408
410
  }
411
+ // --- the space's own tables and pages --------------------------------------------
412
+ // What the admin made under Space → Daten on the install, not what a wizard keeps: those stay
413
+ // where their runs write them. Sent as a whole, it replaces what came before.
414
+ export const syncDataSchema = z.object({
415
+ tables: z
416
+ .array(z.object({
417
+ id: syncedId,
418
+ title: z.string().min(1).max(120),
419
+ columns: tableColumnsSchema,
420
+ rows: z
421
+ .array(z.object({ id: syncedId, cells: z.record(z.string(), z.unknown()) }))
422
+ .max(SPACE_DATA_LIMITS.rowsPerTable),
423
+ }))
424
+ .max(SPACE_DATA_LIMITS.tables),
425
+ pages: z
426
+ .array(z.object({
427
+ id: syncedId,
428
+ title: z.string().min(1).max(200),
429
+ markdown: z.string().max(SPACE_DATA_LIMITS.pageChars),
430
+ }))
431
+ .max(SPACE_DATA_LIMITS.pages),
432
+ });
433
+ /**
434
+ * Takes the space's own tables and pages a local install sent: those it no longer has go, the
435
+ * others are written under the ids they have there. The space must be here already: it comes
436
+ * with the first wizard.
437
+ */
438
+ export async function syncSpaceData(rawSpaceId, input) {
439
+ const spaceId = syncedId.parse(rawSpaceId);
440
+ const project = await syncedProject(spaceId);
441
+ if (project?.origin !== "local") {
442
+ throw notFound();
443
+ }
444
+ const own = (table) => and(eq(table.projectId, spaceId), isNull(table.wizardId));
445
+ const tableIds = input.tables.map((t) => t.id);
446
+ const pageIds = input.pages.map((p) => p.id);
447
+ // An id this tenant uses for something else is not taken over.
448
+ const [tablesHere, pagesHere] = await Promise.all([
449
+ tableIds.length
450
+ ? db.query.spaceTable.findMany({
451
+ where: inArray(schema.spaceTable.id, tableIds),
452
+ columns: { id: true, projectId: true, wizardId: true },
453
+ })
454
+ : [],
455
+ pageIds.length
456
+ ? db.query.spacePage.findMany({
457
+ where: inArray(schema.spacePage.id, pageIds),
458
+ columns: { id: true, projectId: true, wizardId: true },
459
+ })
460
+ : [],
461
+ ]);
462
+ if ([...tablesHere, ...pagesHere].some((x) => x.projectId !== spaceId || x.wizardId !== null)) {
463
+ throw new ServiceError("refused", "Eine Kennung ist hier schon vergeben.", {
464
+ reason: "id_taken",
465
+ });
466
+ }
467
+ const now = new Date();
468
+ await inTransaction(async () => {
469
+ await db
470
+ .delete(schema.spaceTable)
471
+ .where(and(own(schema.spaceTable), tableIds.length ? notInArray(schema.spaceTable.id, tableIds) : undefined));
472
+ await db
473
+ .delete(schema.spacePage)
474
+ .where(and(own(schema.spacePage), pageIds.length ? notInArray(schema.spacePage.id, pageIds) : undefined));
475
+ for (const table of input.tables) {
476
+ const values = { title: table.title, columns: table.columns, updatedAt: now };
477
+ await db
478
+ .insert(schema.spaceTable)
479
+ .values({ id: table.id, tenantId: currentTenant(), projectId: spaceId, ...values })
480
+ .onConflictDoUpdate({ target: schema.spaceTable.id, set: values });
481
+ await db.delete(schema.spaceTableRow).where(eq(schema.spaceTableRow.tableId, table.id));
482
+ for (let i = 0; i < table.rows.length; i += 200) {
483
+ const batch = table.rows.slice(i, i + 200);
484
+ // The order they stand in there: one millisecond apart.
485
+ await db.insert(schema.spaceTableRow).values(batch.map((row, j) => ({
486
+ id: row.id,
487
+ tableId: table.id,
488
+ cells: row.cells,
489
+ createdAt: new Date(now.getTime() - table.rows.length + i + j),
490
+ })));
491
+ }
492
+ }
493
+ for (const page of input.pages) {
494
+ const values = { title: page.title, markdown: page.markdown, updatedAt: now };
495
+ await db
496
+ .insert(schema.spacePage)
497
+ .values({ id: page.id, tenantId: currentTenant(), projectId: spaceId, ...values })
498
+ .onConflictDoUpdate({ target: schema.spacePage.id, set: values });
499
+ }
500
+ });
501
+ return { tables: input.tables.length, pages: input.pages.length };
502
+ }
@@ -0,0 +1,3 @@
1
+ /** A store refused: a list is full, a path is not one, a row is there already. */
2
+ export class StoreError extends Error {
3
+ }
@@ -6,8 +6,9 @@ import { db, schema } from "../db/client.js";
6
6
  import { env } from "../env.js";
7
7
  import { getBlob, putBlob } from "../files/blobs.js";
8
8
  import { seal, unseal } from "../secrets/crypto.js";
9
- export class StoreError extends Error {
10
- }
9
+ import { StoreError } from "./errors.js";
10
+ import { deleteSharedRows, saveSharedRows, sharedRows } from "./shared-lists.js";
11
+ export { StoreError };
11
12
  /** The person a run belongs to: the signed-in admin, else the visitor of the shared link. */
12
13
  export function holderOf(run) {
13
14
  if (run.userId) {
@@ -33,9 +34,13 @@ const inList = (scope, list) => and(eq(schema.storeRow.wizardId, scope.wizardId)
33
34
  function toRow(r) {
34
35
  return { id: r.id, cells: r.cells, updatedAt: r.updatedAt.toISOString() };
35
36
  }
36
- export async function listRows(scope, list) {
37
+ /** The rows of a list: the person's, or every run's where the list is shared. */
38
+ export async function listRows(scope, def) {
39
+ if (def.shared) {
40
+ return sharedRows(scope.wizardId, def);
41
+ }
37
42
  const rows = await db.query.storeRow.findMany({
38
- where: inList(scope, list),
43
+ where: inList(scope, def.id),
39
44
  orderBy: [asc(schema.storeRow.createdAt), asc(schema.storeRow.id)],
40
45
  });
41
46
  return rows.map(toRow);
@@ -46,6 +51,9 @@ export async function listRows(scope, list) {
46
51
  * A row with a bad value is refused as a whole (`TableColumnValueError` names the column).
47
52
  */
48
53
  export async function saveRows(scope, def, input) {
54
+ if (def.shared) {
55
+ return saveSharedRows(scope.wizardId, def, input);
56
+ }
49
57
  let added = 0;
50
58
  let updated = 0;
51
59
  const existing = await db.query.storeRow.findMany({ where: inList(scope, def.id) });
@@ -82,8 +90,11 @@ export async function saveRows(scope, def, input) {
82
90
  await touchStore(scope);
83
91
  return { added, updated };
84
92
  }
85
- /** Changes cells of one row (the person edited it). */
93
+ /** Changes cells of one row (the person edited it). A shared list is never the person's. */
86
94
  export async function updateRow(scope, def, rowId, patch) {
95
+ if (def.shared) {
96
+ return false;
97
+ }
87
98
  const row = await db.query.storeRow.findFirst({
88
99
  where: and(inList(scope, def.id), eq(schema.storeRow.id, rowId)),
89
100
  });
@@ -108,6 +119,9 @@ export async function updateRow(scope, def, rowId, patch) {
108
119
  }
109
120
  /** Deletes rows by id, or by the value of the key column. */
110
121
  export async function deleteRows(scope, def, idsOrKeys) {
122
+ if (def.shared) {
123
+ return deleteSharedRows(scope.wizardId, def, idsOrKeys);
124
+ }
111
125
  if (!idsOrKeys.length) {
112
126
  return 0;
113
127
  }
@@ -0,0 +1,124 @@
1
+ import { newCells, patchCells, rowKey, STORE_LIMITS, } from "@engenty-wizards/shared/store";
2
+ import { and, asc, eq, inArray } from "drizzle-orm";
3
+ import { nanoid } from "nanoid";
4
+ import { db, schema } from "../db/client.js";
5
+ import { StoreError } from "./errors.js";
6
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
7
+ /** The table a shared list is kept in: made the first time, following the list as it changes. */
8
+ export async function sharedTable(wizardId, def) {
9
+ const where = and(eq(schema.spaceTable.wizardId, wizardId), eq(schema.spaceTable.list, def.id));
10
+ const found = await db.query.spaceTable.findFirst({ where });
11
+ const shape = { title: def.title, columns: def.columns, keyColumn: def.key ?? null };
12
+ if (found) {
13
+ if (found.title !== shape.title ||
14
+ found.keyColumn !== shape.keyColumn ||
15
+ !same(found.columns, shape.columns)) {
16
+ await db
17
+ .update(schema.spaceTable)
18
+ .set({ ...shape, updatedAt: new Date() })
19
+ .where(eq(schema.spaceTable.id, found.id));
20
+ return { ...found, ...shape };
21
+ }
22
+ return found;
23
+ }
24
+ const wizard = await db.query.wizard.findFirst({
25
+ where: eq(schema.wizard.id, wizardId),
26
+ columns: { projectId: true, tenantId: true },
27
+ });
28
+ if (!wizard) {
29
+ throw new StoreError("The wizard is gone.");
30
+ }
31
+ // Two runs that start at once make it once.
32
+ await db
33
+ .insert(schema.spaceTable)
34
+ .values({ id: nanoid(12), ...wizard, wizardId, list: def.id, ...shape })
35
+ .onConflictDoNothing();
36
+ const made = await db.query.spaceTable.findFirst({ where });
37
+ if (!made) {
38
+ throw new StoreError(`List "${def.id}" could not be made.`);
39
+ }
40
+ return made;
41
+ }
42
+ async function touch(tableId) {
43
+ await db
44
+ .update(schema.spaceTable)
45
+ .set({ updatedAt: new Date() })
46
+ .where(eq(schema.spaceTable.id, tableId));
47
+ }
48
+ function toRow(r) {
49
+ return { id: r.id, cells: r.cells, updatedAt: r.updatedAt.toISOString() };
50
+ }
51
+ export async function sharedRows(wizardId, def) {
52
+ const table = await sharedTable(wizardId, def);
53
+ const rows = await db.query.spaceTableRow.findMany({
54
+ where: eq(schema.spaceTableRow.tableId, table.id),
55
+ orderBy: [asc(schema.spaceTableRow.createdAt), asc(schema.spaceTableRow.id)],
56
+ });
57
+ return rows.map(toRow);
58
+ }
59
+ /** As `saveRows` does for one person's list: a row with a known key updates that row. */
60
+ export async function saveSharedRows(wizardId, def, input) {
61
+ const table = await sharedTable(wizardId, def);
62
+ const existing = await db.query.spaceTableRow.findMany({
63
+ where: eq(schema.spaceTableRow.tableId, table.id),
64
+ });
65
+ const byKey = new Map(existing.filter((r) => r.key).map((r) => [r.key, r]));
66
+ let count = existing.length;
67
+ // Rows stand in the order they came, also those saved in the same second.
68
+ const at = Date.now();
69
+ let added = 0;
70
+ let updated = 0;
71
+ for (const raw of input) {
72
+ const key = rowKey(def, raw);
73
+ const hit = key ? byKey.get(key) : undefined;
74
+ if (hit) {
75
+ const cells = patchCells(def, hit.cells, raw);
76
+ await db
77
+ .update(schema.spaceTableRow)
78
+ .set({ cells, updatedAt: new Date() })
79
+ .where(eq(schema.spaceTableRow.id, hit.id));
80
+ hit.cells = cells;
81
+ updated++;
82
+ continue;
83
+ }
84
+ const cells = newCells(def, raw);
85
+ if (Object.values(cells).every((v) => v === null)) {
86
+ continue;
87
+ }
88
+ if (count >= STORE_LIMITS.rowsPerList) {
89
+ throw new StoreError(`List "${def.id}" is full (${STORE_LIMITS.rowsPerList} rows).`);
90
+ }
91
+ const row = { id: nanoid(14), tableId: table.id, key, cells, createdAt: new Date(at + added) };
92
+ await db.insert(schema.spaceTableRow).values(row);
93
+ if (key) {
94
+ byKey.set(key, { ...row, updatedAt: new Date() });
95
+ }
96
+ count++;
97
+ added++;
98
+ }
99
+ if (added || updated) {
100
+ await touch(table.id);
101
+ }
102
+ return { added, updated };
103
+ }
104
+ /** Deletes rows by id, or by the value of the key column. */
105
+ export async function deleteSharedRows(wizardId, def, idsOrKeys) {
106
+ if (!idsOrKeys.length) {
107
+ return 0;
108
+ }
109
+ const table = await sharedTable(wizardId, def);
110
+ const keys = idsOrKeys.map((k) => k.trim().toLowerCase());
111
+ const inTable = eq(schema.spaceTableRow.tableId, table.id);
112
+ const byId = await db
113
+ .delete(schema.spaceTableRow)
114
+ .where(and(inTable, inArray(schema.spaceTableRow.id, idsOrKeys)))
115
+ .returning({ id: schema.spaceTableRow.id });
116
+ const byKey = await db
117
+ .delete(schema.spaceTableRow)
118
+ .where(and(inTable, inArray(schema.spaceTableRow.key, keys)))
119
+ .returning({ id: schema.spaceTableRow.id });
120
+ if (byId.length + byKey.length) {
121
+ await touch(table.id);
122
+ }
123
+ return byId.length + byKey.length;
124
+ }
@@ -13,6 +13,7 @@ import { browserTools } from "./browser.js";
13
13
  import { connectorTools } from "./connector.js";
14
14
  import { mailTools } from "./mail.js";
15
15
  import { assertPublicUrl, safeFetch } from "./net-guard.js";
16
+ import { pageTools } from "./pages.js";
16
17
  import { pluginTools, spaceContextOf } from "./plugin.js";
17
18
  import { projectTools } from "./project.js";
18
19
  import { clip, FileKeeper } from "./shared.js";
@@ -98,6 +99,9 @@ export async function buildStepTools(step, ctx, resolved, uploads) {
98
99
  Object.assign(tools, await connectorTools(step, ctx, files));
99
100
  Object.assign(tools, await pluginTools(step, ctx));
100
101
  Object.assign(tools, (await spaceContextOf(ctx)).tools);
102
+ if (allowed.has("pages")) {
103
+ Object.assign(tools, pageTools(ctx));
104
+ }
101
105
  const sandbox = sandboxCapabilities();
102
106
  if (allowed.has("sandbox") && sandbox) {
103
107
  const timeoutMs = (seconds) => Math.min((seconds ?? 120) * 1000, 600_000);