@beignet/cli 0.0.32 → 0.0.33
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/CHANGELOG.md +49 -0
- package/README.md +26 -16
- package/dist/choices.d.ts +3 -3
- package/dist/choices.d.ts.map +1 -1
- package/dist/choices.js +2 -0
- package/dist/choices.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/db.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +279 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +797 -58
- package/dist/inspect.js.map +1 -1
- package/dist/make/inbox.d.ts.map +1 -1
- package/dist/make/inbox.js +5 -3
- package/dist/make/inbox.js.map +1 -1
- package/dist/make/payments.d.ts.map +1 -1
- package/dist/make/payments.js +58 -29
- package/dist/make/payments.js.map +1 -1
- package/dist/make/shared.d.ts +11 -2
- package/dist/make/shared.d.ts.map +1 -1
- package/dist/make/shared.js +44 -10
- package/dist/make/shared.js.map +1 -1
- package/dist/make/tenancy.d.ts.map +1 -1
- package/dist/make/tenancy.js +6 -4
- package/dist/make/tenancy.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +129 -55
- package/dist/make.js.map +1 -1
- package/dist/outbox.d.ts +114 -1
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +190 -0
- package/dist/outbox.js.map +1 -1
- package/dist/preflight.js +1 -1
- package/dist/preflight.js.map +1 -1
- package/dist/provider-add.d.ts.map +1 -1
- package/dist/provider-add.js +46 -0
- package/dist/provider-add.js.map +1 -1
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +2 -3
- package/dist/task.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +6 -3
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.js +3 -3
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +2 -0
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/server.d.ts.map +1 -1
- package/dist/templates/server.js +6 -1
- package/dist/templates/server.js.map +1 -1
- package/dist/templates/shared.d.ts +3 -1
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +11 -1
- package/dist/templates/shared.js.map +1 -1
- package/dist/templates/testing.d.ts +5 -0
- package/dist/templates/testing.d.ts.map +1 -0
- package/dist/templates/testing.js +542 -0
- package/dist/templates/testing.js.map +1 -0
- package/dist/templates/todos.js +2 -2
- package/package.json +3 -2
- package/skills/app-structure/SKILL.md +12 -4
- package/src/choices.ts +3 -0
- package/src/config.ts +2 -0
- package/src/db.ts +1 -1
- package/src/index.ts +437 -1
- package/src/inspect.ts +1247 -154
- package/src/make/inbox.ts +5 -3
- package/src/make/payments.ts +58 -29
- package/src/make/shared.ts +78 -11
- package/src/make/tenancy.ts +6 -4
- package/src/make.ts +172 -54
- package/src/outbox.ts +363 -0
- package/src/preflight.ts +1 -1
- package/src/provider-add.ts +47 -0
- package/src/task.ts +2 -3
- package/src/templates/agents.ts +6 -3
- package/src/templates/base.ts +3 -3
- package/src/templates/index.ts +2 -0
- package/src/templates/server.ts +6 -1
- package/src/templates/shared.ts +13 -1
- package/src/templates/testing.ts +545 -0
- package/src/templates/todos.ts +2 -2
- package/src/test-helpers/generated-app.ts +10 -6
package/src/outbox.ts
CHANGED
|
@@ -3,6 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
import type {
|
|
4
4
|
DrainOutboxOptions,
|
|
5
5
|
DrainOutboxResult,
|
|
6
|
+
OutboxAdminPort,
|
|
7
|
+
OutboxMessage,
|
|
8
|
+
OutboxMessageKind,
|
|
9
|
+
OutboxMessageStatus,
|
|
6
10
|
OutboxRegistry,
|
|
7
11
|
} from "@beignet/core/outbox";
|
|
8
12
|
import { createJiti } from "jiti";
|
|
@@ -17,6 +21,61 @@ export type RunOutboxDrainOptions = {
|
|
|
17
21
|
batchSize?: number;
|
|
18
22
|
};
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Options for listing outbox messages.
|
|
26
|
+
*/
|
|
27
|
+
export type RunOutboxListOptions = {
|
|
28
|
+
cwd?: string;
|
|
29
|
+
modulePath?: string;
|
|
30
|
+
status?: OutboxMessageStatus;
|
|
31
|
+
kind?: OutboxMessageKind;
|
|
32
|
+
name?: string;
|
|
33
|
+
limit?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for showing one outbox message.
|
|
38
|
+
*/
|
|
39
|
+
export type RunOutboxShowOptions = {
|
|
40
|
+
cwd?: string;
|
|
41
|
+
modulePath?: string;
|
|
42
|
+
id: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Options for requeueing one dead-lettered outbox message.
|
|
47
|
+
*/
|
|
48
|
+
export type RunOutboxRequeueOptions = {
|
|
49
|
+
cwd?: string;
|
|
50
|
+
modulePath?: string;
|
|
51
|
+
id: string;
|
|
52
|
+
availableAt?: Date;
|
|
53
|
+
resetAttempts?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Options for purging dead-lettered outbox messages.
|
|
58
|
+
*/
|
|
59
|
+
export type RunOutboxPurgeOptions = {
|
|
60
|
+
cwd?: string;
|
|
61
|
+
modulePath?: string;
|
|
62
|
+
before?: Date;
|
|
63
|
+
all?: boolean;
|
|
64
|
+
limit?: number;
|
|
65
|
+
dryRun?: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Options for pruning delivered outbox messages.
|
|
70
|
+
*/
|
|
71
|
+
export type RunOutboxPruneOptions = {
|
|
72
|
+
cwd?: string;
|
|
73
|
+
modulePath?: string;
|
|
74
|
+
before: Date;
|
|
75
|
+
limit?: number;
|
|
76
|
+
dryRun?: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
20
79
|
/**
|
|
21
80
|
* Result returned by `beignet outbox drain`.
|
|
22
81
|
*/
|
|
@@ -28,6 +87,53 @@ export type RunOutboxDrainResult = {
|
|
|
28
87
|
durationMs: number;
|
|
29
88
|
};
|
|
30
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Result returned by `beignet outbox list`.
|
|
92
|
+
*/
|
|
93
|
+
export type RunOutboxListResult = {
|
|
94
|
+
schemaVersion: 1;
|
|
95
|
+
cwd: string;
|
|
96
|
+
modulePath: string;
|
|
97
|
+
messages: readonly OutboxMessage[];
|
|
98
|
+
count: number;
|
|
99
|
+
durationMs: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Result returned by `beignet outbox show`.
|
|
104
|
+
*/
|
|
105
|
+
export type RunOutboxShowResult = {
|
|
106
|
+
schemaVersion: 1;
|
|
107
|
+
cwd: string;
|
|
108
|
+
modulePath: string;
|
|
109
|
+
message: OutboxMessage;
|
|
110
|
+
durationMs: number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Result returned by `beignet outbox requeue`.
|
|
115
|
+
*/
|
|
116
|
+
export type RunOutboxRequeueResult = {
|
|
117
|
+
schemaVersion: 1;
|
|
118
|
+
cwd: string;
|
|
119
|
+
modulePath: string;
|
|
120
|
+
message: OutboxMessage;
|
|
121
|
+
durationMs: number;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Result returned by destructive outbox admin commands.
|
|
126
|
+
*/
|
|
127
|
+
export type RunOutboxDeleteResult = {
|
|
128
|
+
schemaVersion: 1;
|
|
129
|
+
cwd: string;
|
|
130
|
+
modulePath: string;
|
|
131
|
+
dryRun: boolean;
|
|
132
|
+
matched: number;
|
|
133
|
+
deleted: number;
|
|
134
|
+
durationMs: number;
|
|
135
|
+
};
|
|
136
|
+
|
|
31
137
|
type OutboxDrainLogger = {
|
|
32
138
|
error(message: string, meta?: Record<string, unknown>): unknown;
|
|
33
139
|
};
|
|
@@ -47,6 +153,7 @@ type OutboxDrainInstrumentation = {
|
|
|
47
153
|
|
|
48
154
|
type OutboxDrainPorts = {
|
|
49
155
|
outbox: DrainOutboxOptions["outbox"];
|
|
156
|
+
outboxAdmin?: OutboxAdminPort;
|
|
50
157
|
eventBus?: DrainOutboxOptions["eventBus"];
|
|
51
158
|
jobs?: DrainOutboxOptions["jobs"];
|
|
52
159
|
logger?: OutboxDrainLogger;
|
|
@@ -65,13 +172,24 @@ type OutboxDrainContextArgs = {
|
|
|
65
172
|
batchSize?: number;
|
|
66
173
|
};
|
|
67
174
|
|
|
175
|
+
type OutboxAdminOperation = "list" | "show" | "requeue" | "purge" | "prune";
|
|
176
|
+
|
|
177
|
+
type OutboxAdminContextArgs = {
|
|
178
|
+
operation: OutboxAdminOperation;
|
|
179
|
+
};
|
|
180
|
+
|
|
68
181
|
type OutboxModule = {
|
|
69
182
|
outboxRegistry?: unknown;
|
|
70
183
|
createOutboxDrainContext?: (args: OutboxDrainContextArgs) => unknown;
|
|
184
|
+
createOutboxAdminContext?: (args: OutboxAdminContextArgs) => unknown;
|
|
71
185
|
stopOutboxDrainContext?: (
|
|
72
186
|
ctx: OutboxDrainContext,
|
|
73
187
|
args: OutboxDrainContextArgs,
|
|
74
188
|
) => unknown;
|
|
189
|
+
stopOutboxAdminContext?: (
|
|
190
|
+
ctx: OutboxDrainContext,
|
|
191
|
+
args: OutboxAdminContextArgs,
|
|
192
|
+
) => unknown;
|
|
75
193
|
};
|
|
76
194
|
|
|
77
195
|
/**
|
|
@@ -138,6 +256,190 @@ export async function runOutboxDrain(
|
|
|
138
256
|
}
|
|
139
257
|
}
|
|
140
258
|
|
|
259
|
+
/**
|
|
260
|
+
* List outbox messages through the app's outbox admin port.
|
|
261
|
+
*/
|
|
262
|
+
export async function runOutboxList(
|
|
263
|
+
options: RunOutboxListOptions = {},
|
|
264
|
+
): Promise<RunOutboxListResult> {
|
|
265
|
+
return withOutboxAdminContext(options, "list", async ({ admin, base }) => {
|
|
266
|
+
const query = {
|
|
267
|
+
status: options.status,
|
|
268
|
+
kind: options.kind,
|
|
269
|
+
name: options.name,
|
|
270
|
+
};
|
|
271
|
+
const messages = await admin.listMessages({
|
|
272
|
+
...query,
|
|
273
|
+
limit: options.limit,
|
|
274
|
+
});
|
|
275
|
+
const count = await admin.countMessages(query);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
...base,
|
|
279
|
+
messages,
|
|
280
|
+
count,
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Show one outbox message through the app's outbox admin port.
|
|
287
|
+
*/
|
|
288
|
+
export async function runOutboxShow(
|
|
289
|
+
options: RunOutboxShowOptions,
|
|
290
|
+
): Promise<RunOutboxShowResult> {
|
|
291
|
+
return withOutboxAdminContext(options, "show", async ({ admin, base }) => {
|
|
292
|
+
const message = await admin.getMessage(options.id);
|
|
293
|
+
if (!message) {
|
|
294
|
+
throw new Error(`Outbox message "${options.id}" does not exist.`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
...base,
|
|
299
|
+
message,
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Requeue one dead-lettered outbox message.
|
|
306
|
+
*/
|
|
307
|
+
export async function runOutboxRequeue(
|
|
308
|
+
options: RunOutboxRequeueOptions,
|
|
309
|
+
): Promise<RunOutboxRequeueResult> {
|
|
310
|
+
return withOutboxAdminContext(options, "requeue", async ({ admin, base }) => {
|
|
311
|
+
const message = await admin.requeueMessage({
|
|
312
|
+
id: options.id,
|
|
313
|
+
availableAt: options.availableAt,
|
|
314
|
+
resetAttempts: options.resetAttempts,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...base,
|
|
319
|
+
message,
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Purge dead-lettered outbox messages.
|
|
326
|
+
*/
|
|
327
|
+
export async function runOutboxPurge(
|
|
328
|
+
options: RunOutboxPurgeOptions = {},
|
|
329
|
+
): Promise<RunOutboxDeleteResult> {
|
|
330
|
+
if (!options.before && !options.all) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"Pass --before <date> to purge old dead-lettered messages, or pass --all to purge every dead-lettered message.",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return withOutboxAdminContext(options, "purge", async ({ admin, base }) => {
|
|
337
|
+
const query = {
|
|
338
|
+
status: "deadLettered" as const,
|
|
339
|
+
updatedBefore: options.before,
|
|
340
|
+
};
|
|
341
|
+
const matched = await admin.countMessages(query);
|
|
342
|
+
const result = options.dryRun
|
|
343
|
+
? { deleted: dryRunDeleteCount(matched, options.limit) }
|
|
344
|
+
: await admin.purgeDeadLettered({
|
|
345
|
+
before: options.before,
|
|
346
|
+
limit: options.limit,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
...base,
|
|
351
|
+
dryRun: Boolean(options.dryRun),
|
|
352
|
+
matched,
|
|
353
|
+
deleted: result.deleted,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Prune delivered outbox messages.
|
|
360
|
+
*/
|
|
361
|
+
export async function runOutboxPrune(
|
|
362
|
+
options: RunOutboxPruneOptions,
|
|
363
|
+
): Promise<RunOutboxDeleteResult> {
|
|
364
|
+
return withOutboxAdminContext(options, "prune", async ({ admin, base }) => {
|
|
365
|
+
const query = {
|
|
366
|
+
status: "delivered" as const,
|
|
367
|
+
deliveredBefore: options.before,
|
|
368
|
+
};
|
|
369
|
+
const matched = await admin.countMessages(query);
|
|
370
|
+
const result = options.dryRun
|
|
371
|
+
? { deleted: dryRunDeleteCount(matched, options.limit) }
|
|
372
|
+
: await admin.pruneDelivered({
|
|
373
|
+
before: options.before,
|
|
374
|
+
limit: options.limit,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
...base,
|
|
379
|
+
dryRun: Boolean(options.dryRun),
|
|
380
|
+
matched,
|
|
381
|
+
deleted: result.deleted,
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function withOutboxAdminContext<T extends object>(
|
|
387
|
+
options: { cwd?: string; modulePath?: string },
|
|
388
|
+
operation: OutboxAdminOperation,
|
|
389
|
+
run: (args: {
|
|
390
|
+
admin: OutboxAdminPort;
|
|
391
|
+
base: {
|
|
392
|
+
schemaVersion: 1;
|
|
393
|
+
cwd: string;
|
|
394
|
+
modulePath: string;
|
|
395
|
+
};
|
|
396
|
+
}) => Promise<T>,
|
|
397
|
+
): Promise<T & { durationMs: number }> {
|
|
398
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
399
|
+
const config = await loadBeignetConfig(cwd);
|
|
400
|
+
const modulePath = normalizePath(options.modulePath ?? config.paths.outbox);
|
|
401
|
+
const startedAt = performance.now();
|
|
402
|
+
const outboxModule = await loadOutboxModule(cwd, modulePath);
|
|
403
|
+
const contextArgs = { operation };
|
|
404
|
+
const rawContext = outboxModule.createOutboxAdminContext
|
|
405
|
+
? await outboxModule.createOutboxAdminContext(contextArgs)
|
|
406
|
+
: await createFallbackOutboxAdminContext(outboxModule);
|
|
407
|
+
const ctx = assertOutboxAdminContext(rawContext, modulePath);
|
|
408
|
+
const admin = resolveOutboxAdminPort(ctx, modulePath);
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const result = await run({
|
|
412
|
+
admin,
|
|
413
|
+
base: {
|
|
414
|
+
schemaVersion: 1,
|
|
415
|
+
cwd,
|
|
416
|
+
modulePath,
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
return {
|
|
420
|
+
...result,
|
|
421
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
422
|
+
};
|
|
423
|
+
} finally {
|
|
424
|
+
if (outboxModule.stopOutboxAdminContext) {
|
|
425
|
+
await outboxModule.stopOutboxAdminContext(ctx, contextArgs);
|
|
426
|
+
} else {
|
|
427
|
+
await outboxModule.stopOutboxDrainContext?.(ctx, {
|
|
428
|
+
registry: maybeOutboxRegistry(outboxModule.outboxRegistry),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function createFallbackOutboxAdminContext(
|
|
435
|
+
outboxModule: OutboxModule,
|
|
436
|
+
): Promise<unknown> {
|
|
437
|
+
if (!outboxModule.createOutboxDrainContext) return undefined;
|
|
438
|
+
return outboxModule.createOutboxDrainContext({
|
|
439
|
+
registry: maybeOutboxRegistry(outboxModule.outboxRegistry),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
141
443
|
async function loadOutboxModule(
|
|
142
444
|
cwd: string,
|
|
143
445
|
modulePath: string,
|
|
@@ -197,6 +499,19 @@ function assertOutboxDrainContext(
|
|
|
197
499
|
return ctx as OutboxDrainContext;
|
|
198
500
|
}
|
|
199
501
|
|
|
502
|
+
function assertOutboxAdminContext(
|
|
503
|
+
ctx: unknown,
|
|
504
|
+
modulePath: string,
|
|
505
|
+
): OutboxDrainContext {
|
|
506
|
+
if (!isRecord(ctx) || !isRecord(ctx.ports)) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`${modulePath} must export createOutboxAdminContext or createOutboxDrainContext that returns an app context with ports.outboxAdmin.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return ctx as OutboxDrainContext;
|
|
513
|
+
}
|
|
514
|
+
|
|
200
515
|
function isOutboxPort(value: unknown): value is DrainOutboxOptions["outbox"] {
|
|
201
516
|
return (
|
|
202
517
|
isRecord(value) &&
|
|
@@ -207,6 +522,48 @@ function isOutboxPort(value: unknown): value is DrainOutboxOptions["outbox"] {
|
|
|
207
522
|
);
|
|
208
523
|
}
|
|
209
524
|
|
|
525
|
+
function isOutboxAdminPort(value: unknown): value is OutboxAdminPort {
|
|
526
|
+
return (
|
|
527
|
+
isRecord(value) &&
|
|
528
|
+
typeof value.listMessages === "function" &&
|
|
529
|
+
typeof value.countMessages === "function" &&
|
|
530
|
+
typeof value.getMessage === "function" &&
|
|
531
|
+
typeof value.requeueMessage === "function" &&
|
|
532
|
+
typeof value.purgeDeadLettered === "function" &&
|
|
533
|
+
typeof value.pruneDelivered === "function"
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function resolveOutboxAdminPort(
|
|
538
|
+
ctx: OutboxDrainContext,
|
|
539
|
+
modulePath: string,
|
|
540
|
+
): OutboxAdminPort {
|
|
541
|
+
if (isOutboxAdminPort(ctx.ports.outboxAdmin)) return ctx.ports.outboxAdmin;
|
|
542
|
+
if (isOutboxAdminPort(ctx.ports.outbox)) return ctx.ports.outbox;
|
|
543
|
+
|
|
544
|
+
throw new Error(
|
|
545
|
+
`${modulePath} createOutboxAdminContext must return ports.outboxAdmin, or ports.outbox must implement OutboxAdminPort.`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function maybeOutboxRegistry(registry: unknown): OutboxRegistry {
|
|
550
|
+
if (
|
|
551
|
+
isRecord(registry) &&
|
|
552
|
+
registry.events instanceof Map &&
|
|
553
|
+
registry.jobs instanceof Map
|
|
554
|
+
) {
|
|
555
|
+
return {
|
|
556
|
+
events: registry.events,
|
|
557
|
+
jobs: registry.jobs,
|
|
558
|
+
} as unknown as OutboxRegistry;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
events: new Map(),
|
|
563
|
+
jobs: new Map(),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
210
567
|
async function recordOutboxDrain(
|
|
211
568
|
ctx: OutboxDrainContext,
|
|
212
569
|
result: DrainOutboxResult,
|
|
@@ -235,6 +592,12 @@ function assertPositiveInteger(name: string, value: number): void {
|
|
|
235
592
|
}
|
|
236
593
|
}
|
|
237
594
|
|
|
595
|
+
function dryRunDeleteCount(matched: number, limit: number | undefined): number {
|
|
596
|
+
if (limit === undefined) return matched;
|
|
597
|
+
assertPositiveInteger("limit", limit);
|
|
598
|
+
return Math.min(matched, limit);
|
|
599
|
+
}
|
|
600
|
+
|
|
238
601
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
239
602
|
return typeof value === "object" && value !== null;
|
|
240
603
|
}
|
package/src/preflight.ts
CHANGED
|
@@ -283,7 +283,7 @@ export async function runPreflight(
|
|
|
283
283
|
severity: "note",
|
|
284
284
|
code: "BEIGNET_PREFLIGHT_MANUAL_REVIEW",
|
|
285
285
|
message:
|
|
286
|
-
"Manual review
|
|
286
|
+
"Manual review: CSRF policy, CSP/HSTS values, trusted proxy topology, and tracing export — see the deployment docs hardening checklist.",
|
|
287
287
|
});
|
|
288
288
|
|
|
289
289
|
let envModuleChecked = false;
|
package/src/provider-add.ts
CHANGED
|
@@ -455,6 +455,53 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
|
|
|
455
455
|
"Run beignet providers audit and beignet doctor --strict.",
|
|
456
456
|
],
|
|
457
457
|
},
|
|
458
|
+
"event-bus-redis": {
|
|
459
|
+
name: "event-bus-redis",
|
|
460
|
+
displayName: "Redis event bus",
|
|
461
|
+
importName: "redisEventBusProvider",
|
|
462
|
+
importFrom: "@beignet/provider-event-bus-redis",
|
|
463
|
+
providerEntry: "redisEventBusProvider",
|
|
464
|
+
dependencies: (packageJson) => ({
|
|
465
|
+
"@beignet/provider-event-bus-redis":
|
|
466
|
+
beignetDependencyVersion(packageJson),
|
|
467
|
+
ioredis: externalVersions.ioredis,
|
|
468
|
+
}),
|
|
469
|
+
appPort: {
|
|
470
|
+
key: "eventBus",
|
|
471
|
+
typeName: "EventBusPort",
|
|
472
|
+
importName: "EventBusPort",
|
|
473
|
+
importFrom: "@beignet/core/ports",
|
|
474
|
+
},
|
|
475
|
+
envVars: ["REDIS_EVENT_BUS_URL"],
|
|
476
|
+
envExample: [
|
|
477
|
+
"# Redis event bus provider",
|
|
478
|
+
"REDIS_EVENT_BUS_URL=redis://localhost:6379",
|
|
479
|
+
"# REDIS_EVENT_BUS_DB=0",
|
|
480
|
+
"# REDIS_EVENT_BUS_CHANNEL_PREFIX=beignet:event-bus:",
|
|
481
|
+
"",
|
|
482
|
+
].join("\n"),
|
|
483
|
+
docs: [
|
|
484
|
+
"## Redis event bus",
|
|
485
|
+
"",
|
|
486
|
+
"- Package: `@beignet/provider-event-bus-redis`",
|
|
487
|
+
"- Peer dependency: `ioredis`",
|
|
488
|
+
"- The preset wires `redisEventBusProvider` in `server/providers.ts` and defers `eventBus` in `infra/app-ports.ts`.",
|
|
489
|
+
"- Set `REDIS_EVENT_BUS_URL` before booting the app.",
|
|
490
|
+
"- Redis Pub/Sub delivery is best-effort and cross-process, but not durable: use outbox/jobs for side effects that need retry, replay, or dead-letter handling.",
|
|
491
|
+
"- Use `ctx.ports.eventBus` in app code; `ctx.ports.redisEventBus` is available as an escape hatch.",
|
|
492
|
+
"",
|
|
493
|
+
].join("\n"),
|
|
494
|
+
nextSteps: [
|
|
495
|
+
"Run your package manager install command.",
|
|
496
|
+
"Set REDIS_EVENT_BUS_URL in .env.local or your deployment environment.",
|
|
497
|
+
"Keep durable side effects on the outbox/jobs path; Redis Pub/Sub does not replay missed events.",
|
|
498
|
+
"Run beignet providers audit and beignet doctor --strict.",
|
|
499
|
+
],
|
|
500
|
+
conflictingProviderEntries: [
|
|
501
|
+
"createInMemoryEventBusProvider",
|
|
502
|
+
"createRedisEventBusProvider",
|
|
503
|
+
],
|
|
504
|
+
},
|
|
458
505
|
"redis-locks": {
|
|
459
506
|
name: "redis-locks",
|
|
460
507
|
displayName: "Redis locks",
|
package/src/task.ts
CHANGED
|
@@ -50,7 +50,7 @@ export async function runAppTask(
|
|
|
50
50
|
const task = findTask(taskModule.tasks, options.name, modulePath);
|
|
51
51
|
// Loaded lazily so CLI commands that never touch the app runtime, such as
|
|
52
52
|
// `beignet lint`, work without a built @beignet/core.
|
|
53
|
-
const { parseTaskInput } = await import("@beignet/core/tasks");
|
|
53
|
+
const { parseTaskInput, runTask } = await import("@beignet/core/tasks");
|
|
54
54
|
const parsedInput = await parseTaskInput(task, rawInput);
|
|
55
55
|
const contextArgs: TaskRunContextArgs = {
|
|
56
56
|
task,
|
|
@@ -63,8 +63,7 @@ export async function runAppTask(
|
|
|
63
63
|
: {};
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
const output = await task
|
|
67
|
-
task,
|
|
66
|
+
const output = await runTask(task, {
|
|
68
67
|
input: parsedInput,
|
|
69
68
|
ctx,
|
|
70
69
|
});
|
package/src/templates/agents.ts
CHANGED
|
@@ -27,7 +27,9 @@ export function agentsMd(ctx: TemplateContext): string {
|
|
|
27
27
|
const intent = intentRunner(ctx);
|
|
28
28
|
const capabilityClientRow = ctx.api
|
|
29
29
|
? ""
|
|
30
|
-
: "\n| Query cache keys and invalidation | `rq(contract).invalidate(queryClient, params?)`, `.key(...)`, `.filter(...)` from `client/` (`@beignet/react-query`). Thin named wrappers in `features/<feature>/client/` are the convention — hand-built key arrays are not. |"
|
|
30
|
+
: "\n| Query cache keys and invalidation | `rq(contract).invalidate(queryClient, params?)`, `.key(...)`, `.filter(...)` from `client/` (`@beignet/react-query`). Thin named wrappers in `features/<feature>/client/` are the convention — hand-built key arrays are not. |" +
|
|
31
|
+
"\n| Server Component request context | Use an app-owned server-only helper such as `lib/server-context.ts` that wraps `server.createContextFromNext()` in React `cache(...)`. Layouts may read `ctx.auth`, `ctx.tenant`, and request metadata for redirects and shell state; feature data still belongs behind use cases. |" +
|
|
32
|
+
"\n| Server Component React Query prefetch | Use an app-owned helper such as `lib/server-react-query.ts` on top of the request context: keep the `rq(contract).queryOptions(...)` key, replace only the server `queryFn` with the direct use case call, and do not create a parallel `rqServer` abstraction. |";
|
|
31
33
|
const specificSkills = ctx.api
|
|
32
34
|
? "`@beignet/next#routes-server`, `@beignet/provider-db-drizzle#database-provider`, `@beignet/provider-auth-better-auth#auth-provider`, or `@beignet/cli#app-structure`"
|
|
33
35
|
: "`@beignet/next#routes-server`, `@beignet/react-query#client`, `@beignet/react-hook-form#forms`, `@beignet/provider-db-drizzle#database-provider`, `@beignet/provider-auth-better-auth#auth-provider`, or `@beignet/cli#app-structure`";
|
|
@@ -50,8 +52,8 @@ is a silent failure — the file exists but never runs:
|
|
|
50
52
|
- Tasks must be added to \`defineTasks([...])\` in \`server/tasks.ts\`.
|
|
51
53
|
- Outbox events and jobs must be registered in \`defineOutboxRegistry({...})\`
|
|
52
54
|
in \`server/outbox.ts\`.
|
|
53
|
-
- Listeners must be
|
|
54
|
-
wiring.
|
|
55
|
+
- Listeners must be added to the \`listeners\` array in \`server/listeners.ts\`
|
|
56
|
+
and wired through a \`registerListeners(...)\` call in server provider wiring.
|
|
55
57
|
|
|
56
58
|
The starter ships no workflow registries — generators create them on first
|
|
57
59
|
use, so their absence is fine. \`${cli} make event\`, \`${cli} make job\`,
|
|
@@ -81,6 +83,7 @@ apps have reimplemented by accident:
|
|
|
81
83
|
| Ports outside a request — auth callbacks, module-level helpers | \`const { ports } = await getServer()\` (dynamic \`import("@/server")\` breaks module cycles). Do not construct parallel provider clients or fall back to \`console.*\` when \`ports.logger\` exists. |
|
|
82
84
|
| Routes that cannot be contracts — webhooks, third-party callbacks, streaming | \`createWebhookRoute\`, \`createPaymentWebhookRoute\`, \`createScheduleRoute\`, \`createOutboxDrainRoute\` from \`@beignet/next\`; \`server.rawRoute(...)\` for anything else. All run the hooks pipeline — never hand-enforce rate limits in a route body. |
|
|
83
85
|
| Rate limiting or idempotency on a route | Declare \`metadata.rateLimit\` / \`metadata.idempotency\` on the contract (or the \`pipeline\` option on raw routes); hooks enforce it. |
|
|
86
|
+
| Tenant-owned repository access | Pass \`TenantScope\` through app-facing repository methods and unwrap it with \`tenantScopeId(scope)\` in adapters. Raw provider-correlation lookups such as webhook customer IDs are not tenant authorization. |
|
|
84
87
|
| Route-level tests that exercise real hooks | \`createTestApp\` / \`createTestRequester\` from \`@beignet/web/testing\`. Bind the rate-limit and idempotency ports in the test app when asserting 429s or replay. |
|
|
85
88
|
| Environment configuration | \`lib/env.ts\` (\`createEnv\`), never ad-hoc \`process.env\` reads in app code. |
|
|
86
89
|
| The same lookup runs several times in one request — context, policy, use case | Wrap the repository read in \`createMemo(...)\` from \`@beignet/core/memo\` where the adapter is wired in infra. The cache lives for exactly one request; pair mutations with \`.invalidate(...)\`. Never hand-roll per-request caches. |
|
package/src/templates/base.ts
CHANGED
|
@@ -67,11 +67,11 @@ export function packageJson(ctx: TemplateContext): string {
|
|
|
67
67
|
"@beignet/cli": ctx.beignetVersion,
|
|
68
68
|
"@beignet/web": ctx.beignetVersion,
|
|
69
69
|
"@biomejs/biome": externalVersions.biome,
|
|
70
|
-
"@types/bun": externalVersions.typesBun,
|
|
71
70
|
"@types/node": externalVersions.typesNode,
|
|
72
71
|
"@types/react": externalVersions.typesReact,
|
|
73
72
|
"@types/react-dom": externalVersions.typesReactDom,
|
|
74
73
|
"drizzle-kit": externalVersions.drizzleKit,
|
|
74
|
+
tsx: externalVersions.tsx,
|
|
75
75
|
typescript: externalVersions.typescript,
|
|
76
76
|
};
|
|
77
77
|
|
|
@@ -97,11 +97,11 @@ export function packageJson(ctx: TemplateContext): string {
|
|
|
97
97
|
start: "next start",
|
|
98
98
|
lint: "biome lint .",
|
|
99
99
|
format: "biome format --write .",
|
|
100
|
-
test: "
|
|
100
|
+
test: "tsx lib/beignet-test-runner.ts",
|
|
101
101
|
typecheck: "tsc --noEmit",
|
|
102
102
|
"db:generate": "drizzle-kit generate",
|
|
103
103
|
"db:migrate": "drizzle-kit migrate",
|
|
104
|
-
"db:reset": "
|
|
104
|
+
"db:reset": "tsx infra/db/reset.ts",
|
|
105
105
|
};
|
|
106
106
|
|
|
107
107
|
return json({
|
package/src/templates/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { shadcnTemplateFiles } from "./shadcn.js";
|
|
29
29
|
import type { TemplateContext, TemplateFile } from "./shared.js";
|
|
30
30
|
import { shellTemplateFiles } from "./shell.js";
|
|
31
|
+
import { testSupportTemplateFiles } from "./testing.js";
|
|
31
32
|
import { todosFiles } from "./todos.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -133,6 +134,7 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
|
|
|
133
134
|
{ path: "lib/auth.ts", content: serverFiles.authHelpers },
|
|
134
135
|
{ path: "lib/better-auth.ts", content: betterAuth(ctx) },
|
|
135
136
|
{ path: "lib/tenant.ts", content: serverFiles.tenantHelper },
|
|
137
|
+
...testSupportTemplateFiles,
|
|
136
138
|
{ path: "lib/use-case.ts", content: serverFiles.useCaseBuilder },
|
|
137
139
|
{ path: "server/index.ts", content: server(ctx) },
|
|
138
140
|
{ path: "server/context.ts", content: serverFiles.serverContext },
|
package/src/templates/server.ts
CHANGED
|
@@ -297,7 +297,11 @@ export function server(ctx: TemplateContext): string {
|
|
|
297
297
|
|
|
298
298
|
return `import "@beignet/core/server-only";
|
|
299
299
|
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
300
|
-
import {
|
|
300
|
+
import {
|
|
301
|
+
createErrorReportingHooks,
|
|
302
|
+
createIdempotencyHooks,
|
|
303
|
+
createSecurityHeadersHooks,
|
|
304
|
+
} from "@beignet/core/server";
|
|
301
305
|
import type { AppContext } from "@/app-context";
|
|
302
306
|
import { appPorts } from "@/infra/app-ports";
|
|
303
307
|
import { env } from "@/lib/env";
|
|
@@ -314,6 +318,7 @@ export const getServer = createNextServerLoader(async () => {
|
|
|
314
318
|
${providerConfigEntries}
|
|
315
319
|
},
|
|
316
320
|
hooks: [
|
|
321
|
+
createSecurityHeadersHooks<AppContext>(),
|
|
317
322
|
createErrorReportingHooks<AppContext>(),
|
|
318
323
|
createIdempotencyHooks<AppContext>(),
|
|
319
324
|
],
|
package/src/templates/shared.ts
CHANGED
|
@@ -35,8 +35,8 @@ export const externalVersions = {
|
|
|
35
35
|
next: "^16.2.4",
|
|
36
36
|
react: "^19.2.5",
|
|
37
37
|
reactDom: "^19.2.5",
|
|
38
|
+
tsx: "^4.22.4",
|
|
38
39
|
typescript: "^5.3.0",
|
|
39
|
-
typesBun: "^1.3.13",
|
|
40
40
|
typesNode: "^20.10.0",
|
|
41
41
|
typesReact: "^19.0.0",
|
|
42
42
|
typesReactDom: "^19.0.0",
|
|
@@ -67,6 +67,18 @@ export const externalVersions = {
|
|
|
67
67
|
upstashRedis: "^1.0.0",
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
export const legacyGeneratedPackageScripts: Record<string, string> = {
|
|
71
|
+
"db:reset": "bun infra/db/reset.ts",
|
|
72
|
+
"db:seed": "bun server/seed.ts",
|
|
73
|
+
test: "bun test",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const currentGeneratedPackageScripts: Record<string, string> = {
|
|
77
|
+
"db:reset": "tsx infra/db/reset.ts",
|
|
78
|
+
"db:seed": "tsx server/seed.ts",
|
|
79
|
+
test: "tsx lib/beignet-test-runner.ts",
|
|
80
|
+
};
|
|
81
|
+
|
|
70
82
|
/**
|
|
71
83
|
* Dialect-specific names used when rendering database-aware templates.
|
|
72
84
|
*/
|