@supacloud/cli 0.106.0 → 0.106.2
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/dist/index.js +284 -54
- package/dist/shared/tools/app-feature.d.ts +1 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -21588,16 +21588,16 @@ var starter_metadata_default = {
|
|
|
21588
21588
|
generatedBy: "supacloud/starter-metadata-v1",
|
|
21589
21589
|
packages: {
|
|
21590
21590
|
compiler: {
|
|
21591
|
-
version: "0.50.
|
|
21591
|
+
version: "0.50.1"
|
|
21592
21592
|
},
|
|
21593
21593
|
app: {
|
|
21594
|
-
version: "0.31.
|
|
21594
|
+
version: "0.31.1",
|
|
21595
21595
|
dependencies: {
|
|
21596
21596
|
rxjs: "7.8.2"
|
|
21597
21597
|
}
|
|
21598
21598
|
},
|
|
21599
21599
|
elysia: {
|
|
21600
|
-
version: "0.35.
|
|
21600
|
+
version: "0.35.2"
|
|
21601
21601
|
},
|
|
21602
21602
|
commands: {
|
|
21603
21603
|
version: "0.11.0"
|
|
@@ -26254,8 +26254,11 @@ test("${name} controller preserves an asynchronous read failure", async () => {
|
|
|
26254
26254
|
function featureScaffold(name, className, governance) {
|
|
26255
26255
|
const commandName = `${name}.approve`;
|
|
26256
26256
|
const { permission, rpc, database } = governance;
|
|
26257
|
+
const databaseImportPath = governance.databaseImportPath ?? `../../../${database.source.replace(/\.(?:[cm]?tsx?|mts|cts)$/, "")}`;
|
|
26258
|
+
const rowTypeRoot = database.rowType.split(".")[0];
|
|
26257
26259
|
return {
|
|
26258
26260
|
[`${name}.contracts.ts`]: `import { t } from "elysia";
|
|
26261
|
+
import type { ${rowTypeRoot} } from "${databaseImportPath}";
|
|
26259
26262
|
|
|
26260
26263
|
export const ${className}Params = t.Object({
|
|
26261
26264
|
id: t.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_-]+$" }),
|
|
@@ -26271,12 +26274,15 @@ export const ${className}ApproveBody = t.Object({
|
|
|
26271
26274
|
expectedVersion: t.Integer({ minimum: 1 }),
|
|
26272
26275
|
reason: t.Optional(t.String({ maxLength: 2000 })),
|
|
26273
26276
|
});
|
|
26277
|
+
/** Transport projection; the business result type is derived from the database source below. */
|
|
26274
26278
|
export const ${className}Row = t.Object({
|
|
26275
26279
|
id: t.String(),
|
|
26276
26280
|
state: t.String(),
|
|
26277
26281
|
version: t.Integer({ minimum: 1 }),
|
|
26278
26282
|
});
|
|
26283
|
+
export type ${className}DatabaseRow = ${database.rowType};
|
|
26279
26284
|
export const ${className}Receipt = t.Object({
|
|
26285
|
+
id: t.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_-]+$" }),
|
|
26280
26286
|
operationId: t.String({ minLength: 1, maxLength: 512 }),
|
|
26281
26287
|
status: t.Union([
|
|
26282
26288
|
t.Literal("pending"), t.Literal("confirmed"), t.Literal("failed"), t.Literal("unknown"),
|
|
@@ -26285,6 +26291,7 @@ export const ${className}Receipt = t.Object({
|
|
|
26285
26291
|
version: t.Integer({ minimum: 1 }),
|
|
26286
26292
|
});
|
|
26287
26293
|
export const ${className}JobStatus = t.Object({
|
|
26294
|
+
id: t.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_-]+$" }),
|
|
26288
26295
|
operationId: t.String({ minLength: 1, maxLength: 512 }),
|
|
26289
26296
|
status: t.Union([
|
|
26290
26297
|
t.Literal("queued"), t.Literal("running"), t.Literal("completed"),
|
|
@@ -26297,20 +26304,23 @@ export const ${className}QueryTags = {
|
|
|
26297
26304
|
list: ${JSON.stringify(`${name}.list`)},
|
|
26298
26305
|
detail: ${JSON.stringify(`${name}.detail`)},
|
|
26299
26306
|
receipt: ${JSON.stringify(`${name}.receipt`)},
|
|
26307
|
+
jobStatus: ${JSON.stringify(`${name}.job-status`)},
|
|
26300
26308
|
} as const;
|
|
26301
26309
|
export const ${className}ApproveInvalidateTags = [
|
|
26302
26310
|
${className}QueryTags.list,
|
|
26303
26311
|
${className}QueryTags.detail,
|
|
26304
26312
|
${className}QueryTags.receipt,
|
|
26313
|
+
${className}QueryTags.jobStatus,
|
|
26305
26314
|
] as const;
|
|
26306
26315
|
export const ${className}ListResponse = t.Array(${className}Row);
|
|
26307
26316
|
export const ${className}RefreshInput = t.Object({
|
|
26308
26317
|
id: t.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_-]+$" }),
|
|
26309
26318
|
version: t.Integer({ minimum: 1 }),
|
|
26319
|
+
operationId: t.String({ minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]+$" }),
|
|
26310
26320
|
});
|
|
26311
26321
|
`,
|
|
26312
26322
|
[`${name}.model.ts`]: `import {
|
|
26313
|
-
InjectionToken, type FeatureRequestContext,
|
|
26323
|
+
InjectionToken, type FeatureJobPort, type FeatureOperationPort, type FeatureRequestContext,
|
|
26314
26324
|
} from "@supacloud/app/core";
|
|
26315
26325
|
import type {
|
|
26316
26326
|
${className}ApproveBody,
|
|
@@ -26318,6 +26328,7 @@ import type {
|
|
|
26318
26328
|
${className}Receipt,
|
|
26319
26329
|
${className}ReceiptParams,
|
|
26320
26330
|
${className}JobStatus,
|
|
26331
|
+
${className}DatabaseRow,
|
|
26321
26332
|
${className}Row,
|
|
26322
26333
|
${className}RefreshInput,
|
|
26323
26334
|
} from "./${name}.contracts";
|
|
@@ -26326,7 +26337,7 @@ import type { StaticDecode } from "typebox";
|
|
|
26326
26337
|
export type ${className}ListInput = StaticDecode<typeof ${className}ListQuery>;
|
|
26327
26338
|
export type ${className}ApproveInput = StaticDecode<typeof ${className}ApproveBody>;
|
|
26328
26339
|
export type ${className}ApproveCommand = ${className}ApproveInput & { id: string };
|
|
26329
|
-
export type ${className}Result =
|
|
26340
|
+
export type ${className}Result = ${className}DatabaseRow;
|
|
26330
26341
|
export type ${className}ReceiptResult = StaticDecode<typeof ${className}Receipt>;
|
|
26331
26342
|
export type ${className}RefreshJobInput = StaticDecode<typeof ${className}RefreshInput>;
|
|
26332
26343
|
export type ${className}JobStatusResult = StaticDecode<typeof ${className}JobStatus>;
|
|
@@ -26340,13 +26351,11 @@ export interface ${className}WritePort {
|
|
|
26340
26351
|
approve(context: FeatureRequestContext, input: ${className}ApproveCommand): Promise<${className}Result>;
|
|
26341
26352
|
}
|
|
26342
26353
|
|
|
26343
|
-
export interface ${className}ReceiptPort {
|
|
26344
|
-
lookup(context: FeatureRequestContext, input: { id: string; operationId: string }): Promise<${className}ReceiptResult>;
|
|
26345
|
-
}
|
|
26354
|
+
export interface ${className}ReceiptPort extends FeatureOperationPort<${className}ReceiptResult> {}
|
|
26346
26355
|
|
|
26347
|
-
export interface ${className}JobStatusPort {
|
|
26348
|
-
|
|
26349
|
-
}
|
|
26356
|
+
export interface ${className}JobStatusPort extends FeatureOperationPort<${className}JobStatusResult> {}
|
|
26357
|
+
|
|
26358
|
+
export interface ${className}RefreshPort extends FeatureJobPort<${className}RefreshJobInput> {}
|
|
26350
26359
|
|
|
26351
26360
|
export const ${className}Reader = new InjectionToken<${className}ReadPort>(
|
|
26352
26361
|
"${name}.reader", { scope: "request" },
|
|
@@ -26360,9 +26369,13 @@ export const ${className}Receipts = new InjectionToken<${className}ReceiptPort>(
|
|
|
26360
26369
|
export const ${className}JobStatuses = new InjectionToken<${className}JobStatusPort>(
|
|
26361
26370
|
"${name}.job-statuses", { scope: "request" },
|
|
26362
26371
|
);
|
|
26372
|
+
export const ${className}Refresher = new InjectionToken<${className}RefreshPort>(
|
|
26373
|
+
"${name}.refresher", { scope: "job" },
|
|
26374
|
+
);
|
|
26363
26375
|
`,
|
|
26364
26376
|
[`${name}.service.ts`]: `import {
|
|
26365
|
-
Inject, Injectable, Optional, REQUEST_CONTEXT,
|
|
26377
|
+
FeatureAdapterError, Inject, Injectable, Optional, REQUEST_CONTEXT, requireFeatureOperationResult,
|
|
26378
|
+
requireFeatureRequestContext, requireFeatureTransitionResult,
|
|
26366
26379
|
type FeatureRequestContext,
|
|
26367
26380
|
} from "@supacloud/app/core";
|
|
26368
26381
|
import {
|
|
@@ -26394,35 +26407,44 @@ export class ${className}Service {
|
|
|
26394
26407
|
}
|
|
26395
26408
|
|
|
26396
26409
|
async list(input: Parameters<${className}ReadPort["list"]>[1]): Promise<${className}Result[]> {
|
|
26397
|
-
if (!this.reader) throw new
|
|
26410
|
+
if (!this.reader) throw new FeatureAdapterError("${name}.reader");
|
|
26398
26411
|
return this.reader.list(this.context(), input);
|
|
26399
26412
|
}
|
|
26400
26413
|
|
|
26401
26414
|
async detail(id: string): Promise<${className}Result> {
|
|
26402
|
-
if (!this.reader) throw new
|
|
26415
|
+
if (!this.reader) throw new FeatureAdapterError("${name}.reader");
|
|
26403
26416
|
return this.reader.detail(this.context(), id);
|
|
26404
26417
|
}
|
|
26405
26418
|
|
|
26406
26419
|
async approve(id: string, input: ${className}ApproveInput): Promise<${className}Result> {
|
|
26407
|
-
if (!this.writer) throw new
|
|
26408
|
-
return
|
|
26420
|
+
if (!this.writer) throw new FeatureAdapterError("${name}.writer");
|
|
26421
|
+
return requireFeatureTransitionResult(
|
|
26422
|
+
id,
|
|
26423
|
+
"approved",
|
|
26424
|
+
input.expectedVersion,
|
|
26425
|
+
await this.writer.approve(this.context(), { ...input, id }),
|
|
26426
|
+
);
|
|
26409
26427
|
}
|
|
26410
26428
|
|
|
26411
26429
|
async receipt(id: string, operationId: string): Promise<Awaited<ReturnType<${className}ReceiptPort["lookup"]>>> {
|
|
26412
|
-
if (!this.receipts) throw new
|
|
26413
|
-
|
|
26430
|
+
if (!this.receipts) throw new FeatureAdapterError("${name}.receipts");
|
|
26431
|
+
const input = { id, operationId };
|
|
26432
|
+
// Keep the comparison identity separate from mutable adapter arguments.
|
|
26433
|
+
return requireFeatureOperationResult(input, await this.receipts.lookup(this.context(), { ...input }));
|
|
26414
26434
|
}
|
|
26415
26435
|
|
|
26416
26436
|
async jobStatus(id: string, operationId: string): Promise<${className}JobStatusResult> {
|
|
26417
|
-
if (!this.jobStatuses) throw new
|
|
26418
|
-
|
|
26437
|
+
if (!this.jobStatuses) throw new FeatureAdapterError("${name}.job-statuses");
|
|
26438
|
+
const input = { id, operationId };
|
|
26439
|
+
// Keep the comparison identity separate from mutable adapter arguments.
|
|
26440
|
+
return requireFeatureOperationResult(input, await this.jobStatuses.lookup(this.context(), { ...input }));
|
|
26419
26441
|
}
|
|
26420
26442
|
}
|
|
26421
26443
|
`,
|
|
26422
26444
|
[`${name}.feature.ts`]: `import { Effect } from "effect";
|
|
26423
26445
|
import {
|
|
26424
|
-
Body, Command, Controller, defineFeatureSlice, defineFeatureSpec, Get, Inject, JOB_CONTEXT,
|
|
26425
|
-
Injectable, Job, Param, Post, Query,
|
|
26446
|
+
Body, Command, Controller, defineFeatureSlice, defineFeatureSpec, FeatureAdapterError, Get, Inject, JOB_CONTEXT,
|
|
26447
|
+
Injectable, Job, Optional, Param, Post, Query, requireFeatureJobContext, type FeatureRequestContext,
|
|
26426
26448
|
type FeatureState,
|
|
26427
26449
|
} from "@supacloud/app/core";
|
|
26428
26450
|
import {
|
|
@@ -26436,12 +26458,14 @@ import {
|
|
|
26436
26458
|
${className}RefreshInput,
|
|
26437
26459
|
${className}Row,
|
|
26438
26460
|
} from "./${name}.contracts";
|
|
26461
|
+
import { ${className}Refresher } from "./${name}.model";
|
|
26439
26462
|
import { ${className}Service } from "./${name}.service";
|
|
26440
26463
|
import type {
|
|
26441
26464
|
${className}ApproveInput,
|
|
26442
26465
|
${className}ListInput,
|
|
26443
26466
|
${className}ReceiptResult,
|
|
26444
26467
|
${className}JobStatusResult,
|
|
26468
|
+
${className}RefreshPort,
|
|
26445
26469
|
${className}RefreshJobInput,
|
|
26446
26470
|
${className}Result,
|
|
26447
26471
|
} from "./${name}.model";
|
|
@@ -26461,6 +26485,7 @@ export const featureSpec = defineFeatureSpec({
|
|
|
26461
26485
|
${JSON.stringify(`${name}.list`)},
|
|
26462
26486
|
${JSON.stringify(`${name}.detail`)},
|
|
26463
26487
|
${JSON.stringify(`${name}.receipt`)},
|
|
26488
|
+
${JSON.stringify(`${name}.job-status`)},
|
|
26464
26489
|
],
|
|
26465
26490
|
persistence: {
|
|
26466
26491
|
boundary: "database", optimisticLock: "required", versionField: "version", tenantField: "tenant_id", stateField: "state",
|
|
@@ -26471,6 +26496,39 @@ export const featureSpec = defineFeatureSpec({
|
|
|
26471
26496
|
},
|
|
26472
26497
|
});
|
|
26473
26498
|
export type ${className}State = FeatureState<typeof featureSpec>;
|
|
26499
|
+
type ${className}ListError =
|
|
26500
|
+
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
26501
|
+
| { readonly _tag: "FEATURE_CONTEXT_REQUIRED" }
|
|
26502
|
+
| { readonly _tag: "PERMISSION_DENIED" }
|
|
26503
|
+
| { readonly _tag: "FEATURE_NOT_FOUND" }
|
|
26504
|
+
| { readonly _tag: "FEATURE_ADAPTER_UNAVAILABLE" };
|
|
26505
|
+
type ${className}ApproveError =
|
|
26506
|
+
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
26507
|
+
| { readonly _tag: "FEATURE_CONTEXT_REQUIRED" }
|
|
26508
|
+
| { readonly _tag: "PERMISSION_DENIED" }
|
|
26509
|
+
| { readonly _tag: "FEATURE_TRANSITION_CONFLICT" }
|
|
26510
|
+
| { readonly _tag: "FEATURE_ENTITY_RESULT_MISMATCH" }
|
|
26511
|
+
| { readonly _tag: "VERSION_CONFLICT" }
|
|
26512
|
+
| { readonly _tag: "COMMAND_IDEMPOTENCY_CONFLICT" }
|
|
26513
|
+
| { readonly _tag: "COMMAND_OUTCOME_UNKNOWN" }
|
|
26514
|
+
| { readonly _tag: "FEATURE_ADAPTER_UNAVAILABLE" };
|
|
26515
|
+
type ${className}ReceiptError =
|
|
26516
|
+
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
26517
|
+
| { readonly _tag: "FEATURE_CONTEXT_REQUIRED" }
|
|
26518
|
+
| { readonly _tag: "PERMISSION_DENIED" }
|
|
26519
|
+
| { readonly _tag: "FEATURE_NOT_FOUND" }
|
|
26520
|
+
| { readonly _tag: "COMMAND_RECEIPT_INVALID" }
|
|
26521
|
+
| { readonly _tag: "FEATURE_OPERATION_RESULT_MISMATCH" }
|
|
26522
|
+
| { readonly _tag: "COMMAND_OUTCOME_UNKNOWN" }
|
|
26523
|
+
| { readonly _tag: "FEATURE_ADAPTER_UNAVAILABLE" };
|
|
26524
|
+
type ${className}JobStatusError =
|
|
26525
|
+
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
26526
|
+
| { readonly _tag: "FEATURE_CONTEXT_REQUIRED" }
|
|
26527
|
+
| { readonly _tag: "PERMISSION_DENIED" }
|
|
26528
|
+
| { readonly _tag: "FEATURE_NOT_FOUND" }
|
|
26529
|
+
| { readonly _tag: "FEATURE_OPERATION_RESULT_MISMATCH" }
|
|
26530
|
+
| { readonly _tag: "COMMAND_OUTCOME_UNKNOWN" }
|
|
26531
|
+
| { readonly _tag: "FEATURE_ADAPTER_UNAVAILABLE" };
|
|
26474
26532
|
|
|
26475
26533
|
@Injectable({ scope: "request" })
|
|
26476
26534
|
@Query({ name: "${name}.list", tenantScope: "required" })
|
|
@@ -26506,12 +26564,15 @@ export class Approve${className}Command {
|
|
|
26506
26564
|
tenantScope: "required",
|
|
26507
26565
|
})
|
|
26508
26566
|
export class Refresh${className}Job {
|
|
26509
|
-
constructor(
|
|
26567
|
+
constructor(
|
|
26568
|
+
@Inject(JOB_CONTEXT) private readonly jobContext: unknown,
|
|
26569
|
+
@Inject(${className}Refresher) @Optional() private readonly refresher?: ${className}RefreshPort,
|
|
26570
|
+
) {}
|
|
26510
26571
|
|
|
26511
|
-
async run(
|
|
26512
|
-
const context: FeatureRequestContext =
|
|
26513
|
-
|
|
26514
|
-
|
|
26572
|
+
async run(input: ${className}RefreshJobInput): Promise<void> {
|
|
26573
|
+
const context: FeatureRequestContext = requireFeatureJobContext(this.jobContext, input.operationId);
|
|
26574
|
+
if (!this.refresher) throw new FeatureAdapterError("${name}.refresher");
|
|
26575
|
+
await this.refresher.run(context, input);
|
|
26515
26576
|
}
|
|
26516
26577
|
}
|
|
26517
26578
|
|
|
@@ -26527,9 +26588,18 @@ export class ${className}Controller {
|
|
|
26527
26588
|
query: ${className}ListQuery,
|
|
26528
26589
|
tags: [${JSON.stringify(`${name}.list`)}],
|
|
26529
26590
|
responses: { 200: ${className}ListResponse },
|
|
26530
|
-
effect: {
|
|
26591
|
+
effect: {
|
|
26592
|
+
required: true, dependencies: [], retry: "none",
|
|
26593
|
+
errors: [
|
|
26594
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26595
|
+
{ tag: "FEATURE_CONTEXT_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26596
|
+
{ tag: "PERMISSION_DENIED", status: 403, code: "PERMISSION_DENIED" },
|
|
26597
|
+
{ tag: "FEATURE_NOT_FOUND", status: 404, code: "FEATURE_NOT_FOUND" },
|
|
26598
|
+
{ tag: "FEATURE_ADAPTER_UNAVAILABLE", status: 503, code: "FEATURE_ADAPTER_UNAVAILABLE" },
|
|
26599
|
+
],
|
|
26600
|
+
},
|
|
26531
26601
|
})
|
|
26532
|
-
list(@Query("state") state?: ${className}State): Effect.Effect<${className}Result[],
|
|
26602
|
+
list(@Query("state") state?: ${className}State): Effect.Effect<${className}Result[], ${className}ListError, never> {
|
|
26533
26603
|
return Effect.promise(() => this.listQuery.execute(state === undefined ? {} : { state }));
|
|
26534
26604
|
}
|
|
26535
26605
|
|
|
@@ -26537,9 +26607,18 @@ export class ${className}Controller {
|
|
|
26537
26607
|
params: ${className}Params,
|
|
26538
26608
|
tags: [${JSON.stringify(`${name}.detail`)}],
|
|
26539
26609
|
responses: { 200: ${className}Row },
|
|
26540
|
-
effect: {
|
|
26610
|
+
effect: {
|
|
26611
|
+
required: true, dependencies: [], retry: "none",
|
|
26612
|
+
errors: [
|
|
26613
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26614
|
+
{ tag: "FEATURE_CONTEXT_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26615
|
+
{ tag: "PERMISSION_DENIED", status: 403, code: "PERMISSION_DENIED" },
|
|
26616
|
+
{ tag: "FEATURE_NOT_FOUND", status: 404, code: "FEATURE_NOT_FOUND" },
|
|
26617
|
+
{ tag: "FEATURE_ADAPTER_UNAVAILABLE", status: 503, code: "FEATURE_ADAPTER_UNAVAILABLE" },
|
|
26618
|
+
],
|
|
26619
|
+
},
|
|
26541
26620
|
})
|
|
26542
|
-
detail(@Param("id") id: string): Effect.Effect<${className}Result,
|
|
26621
|
+
detail(@Param("id") id: string): Effect.Effect<${className}Result, ${className}ListError, never> {
|
|
26543
26622
|
return Effect.promise(() => this.service.detail(id));
|
|
26544
26623
|
}
|
|
26545
26624
|
|
|
@@ -26549,12 +26628,25 @@ export class ${className}Controller {
|
|
|
26549
26628
|
responses: { 200: ${className}Row },
|
|
26550
26629
|
command: Approve${className}Command,
|
|
26551
26630
|
idempotency: "required",
|
|
26552
|
-
effect: {
|
|
26631
|
+
effect: {
|
|
26632
|
+
required: true, dependencies: [], retry: "none",
|
|
26633
|
+
errors: [
|
|
26634
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26635
|
+
{ tag: "FEATURE_CONTEXT_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26636
|
+
{ tag: "PERMISSION_DENIED", status: 403, code: "PERMISSION_DENIED" },
|
|
26637
|
+
{ tag: "FEATURE_TRANSITION_CONFLICT", status: 409, code: "FEATURE_TRANSITION_CONFLICT" },
|
|
26638
|
+
{ tag: "FEATURE_ENTITY_RESULT_MISMATCH", status: 503, code: "FEATURE_ENTITY_RESULT_MISMATCH" },
|
|
26639
|
+
{ tag: "VERSION_CONFLICT", status: 409, code: "VERSION_CONFLICT" },
|
|
26640
|
+
{ tag: "COMMAND_IDEMPOTENCY_CONFLICT", status: 409, code: "COMMAND_IDEMPOTENCY_CONFLICT" },
|
|
26641
|
+
{ tag: "COMMAND_OUTCOME_UNKNOWN", status: 503, code: "COMMAND_OUTCOME_UNKNOWN" },
|
|
26642
|
+
{ tag: "FEATURE_ADAPTER_UNAVAILABLE", status: 503, code: "FEATURE_ADAPTER_UNAVAILABLE" },
|
|
26643
|
+
],
|
|
26644
|
+
},
|
|
26553
26645
|
})
|
|
26554
26646
|
approve(
|
|
26555
26647
|
@Param("id") id: string,
|
|
26556
26648
|
@Body() body: ${className}ApproveInput,
|
|
26557
|
-
): Effect.Effect<${className}Result,
|
|
26649
|
+
): Effect.Effect<${className}Result, ${className}ApproveError, never> {
|
|
26558
26650
|
return Effect.promise(() => this.approveCommand.execute(id, body));
|
|
26559
26651
|
}
|
|
26560
26652
|
|
|
@@ -26562,12 +26654,24 @@ export class ${className}Controller {
|
|
|
26562
26654
|
params: ${className}ReceiptParams,
|
|
26563
26655
|
tags: [${JSON.stringify(`${name}.receipt`)}],
|
|
26564
26656
|
responses: { 200: ${className}Receipt },
|
|
26565
|
-
effect: {
|
|
26657
|
+
effect: {
|
|
26658
|
+
required: true, dependencies: [], retry: "none",
|
|
26659
|
+
errors: [
|
|
26660
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26661
|
+
{ tag: "FEATURE_CONTEXT_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26662
|
+
{ tag: "PERMISSION_DENIED", status: 403, code: "PERMISSION_DENIED" },
|
|
26663
|
+
{ tag: "FEATURE_NOT_FOUND", status: 404, code: "FEATURE_NOT_FOUND" },
|
|
26664
|
+
{ tag: "COMMAND_RECEIPT_INVALID", status: 503, code: "COMMAND_RECEIPT_INVALID" },
|
|
26665
|
+
{ tag: "FEATURE_OPERATION_RESULT_MISMATCH", status: 503, code: "FEATURE_OPERATION_RESULT_MISMATCH" },
|
|
26666
|
+
{ tag: "COMMAND_OUTCOME_UNKNOWN", status: 503, code: "COMMAND_OUTCOME_UNKNOWN" },
|
|
26667
|
+
{ tag: "FEATURE_ADAPTER_UNAVAILABLE", status: 503, code: "FEATURE_ADAPTER_UNAVAILABLE" },
|
|
26668
|
+
],
|
|
26669
|
+
},
|
|
26566
26670
|
})
|
|
26567
26671
|
receipt(
|
|
26568
26672
|
@Param("id") id: string,
|
|
26569
26673
|
@Param("operationId") operationId: string,
|
|
26570
|
-
): Effect.Effect<${className}ReceiptResult,
|
|
26674
|
+
): Effect.Effect<${className}ReceiptResult, ${className}ReceiptError, never> {
|
|
26571
26675
|
return Effect.promise(() => this.service.receipt(id, operationId));
|
|
26572
26676
|
}
|
|
26573
26677
|
|
|
@@ -26575,12 +26679,23 @@ export class ${className}Controller {
|
|
|
26575
26679
|
params: ${className}ReceiptParams,
|
|
26576
26680
|
tags: [${JSON.stringify(`${name}.job-status`)}],
|
|
26577
26681
|
responses: { 200: ${className}JobStatus },
|
|
26578
|
-
effect: {
|
|
26682
|
+
effect: {
|
|
26683
|
+
required: true, dependencies: [], retry: "none",
|
|
26684
|
+
errors: [
|
|
26685
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26686
|
+
{ tag: "FEATURE_CONTEXT_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
26687
|
+
{ tag: "PERMISSION_DENIED", status: 403, code: "PERMISSION_DENIED" },
|
|
26688
|
+
{ tag: "FEATURE_NOT_FOUND", status: 404, code: "FEATURE_NOT_FOUND" },
|
|
26689
|
+
{ tag: "FEATURE_OPERATION_RESULT_MISMATCH", status: 503, code: "FEATURE_OPERATION_RESULT_MISMATCH" },
|
|
26690
|
+
{ tag: "COMMAND_OUTCOME_UNKNOWN", status: 503, code: "COMMAND_OUTCOME_UNKNOWN" },
|
|
26691
|
+
{ tag: "FEATURE_ADAPTER_UNAVAILABLE", status: 503, code: "FEATURE_ADAPTER_UNAVAILABLE" },
|
|
26692
|
+
],
|
|
26693
|
+
},
|
|
26579
26694
|
})
|
|
26580
26695
|
jobStatus(
|
|
26581
26696
|
@Param("id") id: string,
|
|
26582
26697
|
@Param("operationId") operationId: string,
|
|
26583
|
-
): Effect.Effect<${className}JobStatusResult,
|
|
26698
|
+
): Effect.Effect<${className}JobStatusResult, ${className}JobStatusError, never> {
|
|
26584
26699
|
return Effect.promise(() => this.service.jobStatus(id, operationId));
|
|
26585
26700
|
}
|
|
26586
26701
|
}
|
|
@@ -26597,7 +26712,9 @@ export const ${className}Feature = defineFeatureSlice({
|
|
|
26597
26712
|
[`${name}.feature.test.ts`]: `import { expect, test } from "bun:test";
|
|
26598
26713
|
import { Refresh${className}Job } from "./${name}.feature";
|
|
26599
26714
|
import { ${className}Service } from "./${name}.service";
|
|
26600
|
-
import {
|
|
26715
|
+
import {
|
|
26716
|
+
${className}Fixture, ${className}JobRequestEnvelope, ${className}RequestContext, ${className}RequestEnvelope,
|
|
26717
|
+
} from "./${name}.fixture";
|
|
26601
26718
|
|
|
26602
26719
|
test("${name} rejects missing trusted tenant context before adapter execution", async () => {
|
|
26603
26720
|
let calls = 0;
|
|
@@ -26611,28 +26728,54 @@ test("${name} rejects missing trusted tenant context before adapter execution",
|
|
|
26611
26728
|
|
|
26612
26729
|
test("${name} rejects missing trusted job context before dispatch", async () => {
|
|
26613
26730
|
const job = new Refresh${className}Job({});
|
|
26614
|
-
await expect(job.run({ id: "example", version: 1 }))
|
|
26731
|
+
await expect(job.run({ id: "example", version: 1, operationId: "operation-example" }))
|
|
26732
|
+
.rejects.toThrow("FEATURE_CONTEXT_REQUIRED");
|
|
26733
|
+
});
|
|
26734
|
+
|
|
26735
|
+
test("${name} rejects a job context correlated to a different operation", async () => {
|
|
26736
|
+
const job = new Refresh${className}Job({
|
|
26737
|
+
...${className}RequestEnvelope,
|
|
26738
|
+
idempotencyKey: "foreign-operation",
|
|
26739
|
+
});
|
|
26740
|
+
await expect(job.run({ id: "example", version: 1, operationId: "operation-example" }))
|
|
26741
|
+
.rejects.toMatchObject({ _tag: "FEATURE_JOB_OPERATION_MISMATCH" });
|
|
26742
|
+
});
|
|
26743
|
+
|
|
26744
|
+
test("${name} runs the refresh adapter with trusted tenant context", async () => {
|
|
26745
|
+
let received: unknown;
|
|
26746
|
+
const job = new Refresh${className}Job(${className}JobRequestEnvelope, {
|
|
26747
|
+
async run(context, input) {
|
|
26748
|
+
received = { context, input };
|
|
26749
|
+
},
|
|
26750
|
+
});
|
|
26751
|
+
await expect(job.run({ id: "example", version: 1, operationId: "operation-example" }))
|
|
26752
|
+
.resolves.toBeUndefined();
|
|
26753
|
+
expect(received).toEqual({
|
|
26754
|
+
context: { ...${className}RequestContext, idempotencyKey: "operation-example" },
|
|
26755
|
+
input: { id: "example", version: 1, operationId: "operation-example" },
|
|
26756
|
+
});
|
|
26615
26757
|
});
|
|
26616
26758
|
|
|
26617
26759
|
test("${name} fails closed until verified read and durable write ports are bound", async () => {
|
|
26618
26760
|
const service = new ${className}Service(${className}RequestEnvelope);
|
|
26619
|
-
await expect(service.list({})).rejects.toThrow("
|
|
26620
|
-
await expect(service.detail("example")).rejects.toThrow("
|
|
26621
|
-
await expect(service.approve("example", { expectedVersion: 1 })).rejects.toThrow("
|
|
26622
|
-
await expect(service.jobStatus("example", "operation-example")).rejects.toThrow("
|
|
26761
|
+
await expect(service.list({})).rejects.toThrow("FEATURE_ADAPTER_UNAVAILABLE");
|
|
26762
|
+
await expect(service.detail("example")).rejects.toThrow("FEATURE_ADAPTER_UNAVAILABLE");
|
|
26763
|
+
await expect(service.approve("example", { expectedVersion: 1 })).rejects.toThrow("FEATURE_ADAPTER_UNAVAILABLE");
|
|
26764
|
+
await expect(service.jobStatus("example", "operation-example")).rejects.toThrow("FEATURE_ADAPTER_UNAVAILABLE");
|
|
26623
26765
|
});
|
|
26624
26766
|
|
|
26625
26767
|
test("${name} preserves denied reads and rejected writes without fallback or retry", async () => {
|
|
26626
26768
|
const denied = new Error("denied");
|
|
26627
26769
|
let attempts = 0;
|
|
26628
|
-
const service = new ${className}Service(${className}RequestEnvelope, {
|
|
26770
|
+
const service = new ${className}Service(${className}RequestEnvelope, undefined, {
|
|
26771
|
+
approve: async () => { attempts++; throw denied; },
|
|
26772
|
+
});
|
|
26773
|
+
const readService = new ${className}Service(${className}RequestEnvelope, {
|
|
26629
26774
|
list: async () => { throw denied; },
|
|
26630
26775
|
detail: async () => { throw denied; },
|
|
26631
|
-
}, {
|
|
26632
|
-
approve: async () => { attempts++; throw denied; },
|
|
26633
26776
|
});
|
|
26634
|
-
await expect(
|
|
26635
|
-
await expect(
|
|
26777
|
+
await expect(readService.list({})).rejects.toBe(denied);
|
|
26778
|
+
await expect(readService.detail("example")).rejects.toBe(denied);
|
|
26636
26779
|
await expect(service.approve("example", { expectedVersion: 1 })).rejects.toBe(denied);
|
|
26637
26780
|
expect(attempts).toBe(1);
|
|
26638
26781
|
});
|
|
@@ -26648,6 +26791,78 @@ test("${name} forwards the authoritative version and result without inventing pe
|
|
|
26648
26791
|
});
|
|
26649
26792
|
expect(await service.approve(${className}Fixture.id, ${className}Fixture.approve)).toBe(row);
|
|
26650
26793
|
});
|
|
26794
|
+
|
|
26795
|
+
test("${name} rejects a writer result that does not prove the declared transition", async () => {
|
|
26796
|
+
const service = new ${className}Service(${className}RequestEnvelope, undefined, {
|
|
26797
|
+
approve: async () => ({ id: "example", state: "draft", version: 2 }),
|
|
26798
|
+
});
|
|
26799
|
+
await expect(service.approve("example", { expectedVersion: 1 }))
|
|
26800
|
+
.rejects.toMatchObject({ _tag: "FEATURE_TRANSITION_CONFLICT" });
|
|
26801
|
+
const stale = new ${className}Service(${className}RequestEnvelope, undefined, {
|
|
26802
|
+
approve: async () => ({ id: "example", state: "approved", version: 1 }),
|
|
26803
|
+
});
|
|
26804
|
+
await expect(stale.approve("example", { expectedVersion: 1 }))
|
|
26805
|
+
.rejects.toMatchObject({ _tag: "VERSION_CONFLICT" });
|
|
26806
|
+
const foreign = new ${className}Service(${className}RequestEnvelope, undefined, {
|
|
26807
|
+
approve: async () => ({ id: "foreign", state: "approved", version: 2 }),
|
|
26808
|
+
});
|
|
26809
|
+
await expect(foreign.approve("example", { expectedVersion: 1 }))
|
|
26810
|
+
.rejects.toMatchObject({ _tag: "FEATURE_ENTITY_RESULT_MISMATCH" });
|
|
26811
|
+
});
|
|
26812
|
+
|
|
26813
|
+
test("${name} rejects operation lookup results for a different operation", async () => {
|
|
26814
|
+
const service = new ${className}Service(${className}RequestEnvelope, undefined, undefined, {
|
|
26815
|
+
lookup: async () => ({
|
|
26816
|
+
id: "example", operationId: "foreign-operation", status: "confirmed", state: "approved", version: 2,
|
|
26817
|
+
}),
|
|
26818
|
+
}, {
|
|
26819
|
+
lookup: async () => ({ id: "example", operationId: "foreign-operation", status: "completed" }),
|
|
26820
|
+
});
|
|
26821
|
+
await expect(service.receipt("example", "operation-example"))
|
|
26822
|
+
.rejects.toThrow("FEATURE_OPERATION_RESULT_MISMATCH");
|
|
26823
|
+
await expect(service.jobStatus("example", "operation-example"))
|
|
26824
|
+
.rejects.toThrow("FEATURE_OPERATION_RESULT_MISMATCH");
|
|
26825
|
+
});
|
|
26826
|
+
|
|
26827
|
+
|
|
26828
|
+
for (const field of ["id", "operationId"] as const) {
|
|
26829
|
+
test("${name} isolates lookup identity when an adapter rewrites " + field, async () => {
|
|
26830
|
+
let calls = 0;
|
|
26831
|
+
const service = new ${className}Service(${className}RequestEnvelope, undefined, undefined, {
|
|
26832
|
+
async lookup(_context, input) {
|
|
26833
|
+
calls++;
|
|
26834
|
+
Object.assign(input, { [field]: "foreign" });
|
|
26835
|
+
return { ...input, status: "confirmed", state: "approved", version: 2 };
|
|
26836
|
+
},
|
|
26837
|
+
}, {
|
|
26838
|
+
async lookup(_context, input) {
|
|
26839
|
+
calls++;
|
|
26840
|
+
Object.assign(input, { [field]: "foreign" });
|
|
26841
|
+
return { ...input, status: "completed" };
|
|
26842
|
+
},
|
|
26843
|
+
});
|
|
26844
|
+
await expect(service.receipt("example", "operation-example"))
|
|
26845
|
+
.rejects.toMatchObject({ _tag: "FEATURE_OPERATION_RESULT_MISMATCH" });
|
|
26846
|
+
await expect(service.jobStatus("example", "operation-example"))
|
|
26847
|
+
.rejects.toMatchObject({ _tag: "FEATURE_OPERATION_RESULT_MISMATCH" });
|
|
26848
|
+
expect(calls).toBe(2);
|
|
26849
|
+
});
|
|
26850
|
+
}
|
|
26851
|
+
|
|
26852
|
+
test("${name} preserves matching lookup results and does not replay failures", async () => {
|
|
26853
|
+
const receipt = { id: "example", operationId: "operation-example", status: "unknown" as const, state: "draft", version: 1 };
|
|
26854
|
+
const status = { id: "example", operationId: "operation-example", status: "queued" as const };
|
|
26855
|
+
let calls = 0;
|
|
26856
|
+
const service = new ${className}Service(${className}RequestEnvelope, undefined, undefined, {
|
|
26857
|
+
async lookup() { calls++; return receipt; },
|
|
26858
|
+
}, {
|
|
26859
|
+
async lookup() { calls++; return status; },
|
|
26860
|
+
});
|
|
26861
|
+
expect(await service.receipt("example", "operation-example")).toBe(receipt);
|
|
26862
|
+
expect(await service.jobStatus("example", "operation-example")).toBe(status);
|
|
26863
|
+
expect(calls).toBe(2);
|
|
26864
|
+
});
|
|
26865
|
+
|
|
26651
26866
|
`,
|
|
26652
26867
|
[`${name}.fixture.ts`]: `import type { ${className}ApproveInput, ${className}Result } from "./${name}.model";
|
|
26653
26868
|
|
|
@@ -26673,6 +26888,11 @@ export const ${className}RequestEnvelope = {
|
|
|
26673
26888
|
identity: { authenticated: true, subject: "reviewer-example" },
|
|
26674
26889
|
access: { tenantId: "tenant-example", permissions: [${JSON.stringify(permission)}] },
|
|
26675
26890
|
} as const satisfies Record<string, unknown>;
|
|
26891
|
+
|
|
26892
|
+
export const ${className}JobRequestEnvelope = {
|
|
26893
|
+
...${className}RequestEnvelope,
|
|
26894
|
+
idempotencyKey: "operation-example",
|
|
26895
|
+
} as const satisfies Record<string, unknown>;
|
|
26676
26896
|
`,
|
|
26677
26897
|
[`${name}.e2e.test.ts`]: `import { expect, test } from "bun:test";
|
|
26678
26898
|
import { ${className}Fixture } from "./${name}.fixture";
|
|
@@ -26845,8 +27065,12 @@ identity-scoped caching/invalidation. Use the generated \`${className}ApproveInv
|
|
|
26845
27065
|
constant for the approval mutation; tags are explicit and never inferred from route names.
|
|
26846
27066
|
There is deliberately no second hand-maintained
|
|
26847
27067
|
HTTP client. A committed approval followed by a failed refresh is still committed;
|
|
26848
|
-
recover its receipt instead of resending.
|
|
26849
|
-
|
|
27068
|
+
recover its receipt instead of resending. Host domain errors must carry a stable
|
|
27069
|
+
_tag, tag, code or error name matching the declared route mapping; an Error message
|
|
27070
|
+
alone is not a machine-readable code. Context failures map to authentication-required,
|
|
27071
|
+
and foreign operation results fail with FEATURE_OPERATION_RESULT_MISMATCH on both
|
|
27072
|
+
lookup routes. Unknown failures remain generic server errors without exposing messages.
|
|
27073
|
+
RequestId propagation and durable adapter implementation remain host responsibilities.
|
|
26850
27074
|
|
|
26851
27075
|
## Required acceptance (not executed by generation)
|
|
26852
27076
|
|
|
@@ -27538,7 +27762,13 @@ async function generateScaffold(args) {
|
|
|
27538
27762
|
if (kind === "module")
|
|
27539
27763
|
writes.push({ path: modulePath, content: moduleScaffold(name), overwrite: args.force === true });
|
|
27540
27764
|
else
|
|
27541
|
-
for (const [file, content] of Object.entries(kind === "feature" ? featureScaffold(name, pascalName(name),
|
|
27765
|
+
for (const [file, content] of Object.entries(kind === "feature" ? featureScaffold(name, pascalName(name), {
|
|
27766
|
+
...featureGovernance,
|
|
27767
|
+
databaseImportPath: (() => {
|
|
27768
|
+
const source = relative7(join13(root, dir, name), join13(root, featureGovernance.database.source)).replaceAll("\\", "/").replace(/\.(?:[cm]?tsx?|mts|cts)$/, "");
|
|
27769
|
+
return source.startsWith(".") ? source : `./${source}`;
|
|
27770
|
+
})()
|
|
27771
|
+
}) : resourceScaffold(name, pascalName(name)))) {
|
|
27542
27772
|
writes.push({ path: join13(dir, name, file).replaceAll("\\", "/"), content });
|
|
27543
27773
|
}
|
|
27544
27774
|
} else {
|
|
@@ -32753,7 +32983,7 @@ async function runProjectDeployment(http, args) {
|
|
|
32753
32983
|
// package.json
|
|
32754
32984
|
var package_default = {
|
|
32755
32985
|
name: "@supacloud/cli",
|
|
32756
|
-
version: "0.106.
|
|
32986
|
+
version: "0.106.2",
|
|
32757
32987
|
description: "Project-scoped CLI for SupaCloud users",
|
|
32758
32988
|
type: "module",
|
|
32759
32989
|
main: "./dist/index.js",
|
|
@@ -32805,7 +33035,7 @@ var package_default = {
|
|
|
32805
33035
|
directory: "packages/cli"
|
|
32806
33036
|
},
|
|
32807
33037
|
dependencies: {
|
|
32808
|
-
"@supacloud/compiler": "^0.50.
|
|
33038
|
+
"@supacloud/compiler": "^0.50.1",
|
|
32809
33039
|
"@supacloud/delivery": "0.21.0",
|
|
32810
33040
|
"@supacloud/db": "0.20.0",
|
|
32811
33041
|
typebox: "1.3.37"
|
|
@@ -32817,7 +33047,7 @@ var package_default = {
|
|
|
32817
33047
|
},
|
|
32818
33048
|
devDependencies: {
|
|
32819
33049
|
"@supabase/supabase-js": "2.118.0",
|
|
32820
|
-
"@supacloud/elysia": "0.35.
|
|
33050
|
+
"@supacloud/elysia": "0.35.2",
|
|
32821
33051
|
"@types/bun": "^1.4.3",
|
|
32822
33052
|
effect: "4.0.5",
|
|
32823
33053
|
elysia: "2.0.0-beta.32",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/cli",
|
|
3
|
-
"version": "0.106.
|
|
3
|
+
"version": "0.106.2",
|
|
4
4
|
"description": "Project-scoped CLI for SupaCloud users",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"directory": "packages/cli"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@supacloud/compiler": "^0.50.
|
|
55
|
+
"@supacloud/compiler": "^0.50.1",
|
|
56
56
|
"@supacloud/delivery": "0.21.0",
|
|
57
57
|
"@supacloud/db": "0.20.0",
|
|
58
58
|
"typebox": "1.3.37"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@supabase/supabase-js": "2.118.0",
|
|
67
|
-
"@supacloud/elysia": "0.35.
|
|
67
|
+
"@supacloud/elysia": "0.35.2",
|
|
68
68
|
"@types/bun": "^1.4.3",
|
|
69
69
|
"effect": "4.0.5",
|
|
70
70
|
"elysia": "2.0.0-beta.32",
|