@supacloud/cli 0.101.4 → 0.103.1
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 +1619 -76
- package/dist/shared/tools/app-evidence.test.d.ts +1 -0
- package/dist/shared/tools/app-starter-attachment-worker.d.ts +2 -2
- package/dist/shared/tools/app-starter-client.d.ts +4 -0
- package/dist/shared/tools/app-starter-client.test.d.ts +1 -0
- package/dist/shared/tools/app-starter-database.d.ts +3 -3
- package/dist/shared/tools/app-starter-execution-observability.d.ts +2 -0
- package/dist/shared/tools/app-starter-postgres.d.ts +2 -2
- package/dist/shared/tools/app-starter-receipts.d.ts +1 -0
- package/dist/shared/tools/app-starter-recovery.test.d.ts +1 -0
- package/dist/shared/tools/app-starter-task-provisioning.d.ts +2 -0
- package/dist/shared/tools/app-starter-task-provisioning.test.d.ts +1 -0
- package/dist/shared/tools/app-starter-tasks.d.ts +4 -0
- package/dist/shared/tools/app-starter-upload.d.ts +2 -2
- package/dist/shared/tools/app-starter-worker-observability.test.d.ts +1 -0
- package/dist/shared/tools/app-task-context.test.d.ts +1 -0
- package/dist/shared/tools/app-tools.d.ts +1 -0
- package/dist/shared/tools/app-verification-plan.d.ts +6 -0
- package/package.json +8 -7
package/dist/index.js
CHANGED
|
@@ -9317,7 +9317,7 @@ var ACTION_POLICY = {
|
|
|
9317
9317
|
env: { local: ["list", "check"] },
|
|
9318
9318
|
app: {
|
|
9319
9319
|
local: ["init", "generate", "dev", "watch", "verify-plan", "compile", "check", "graph", "explain", "export-tools", "context", "doctor", "fix", "plan", "build"],
|
|
9320
|
-
read: ["status", "logs", "transfer-plan", "promote-plan", "rollback-plan", "history", "deploy-plan", "diff", "promote-status", "preview-plan", "previews", "preview-list", "preview-status"],
|
|
9320
|
+
read: ["status", "evidence", "logs", "transfer-plan", "promote-plan", "rollback-plan", "history", "deploy-plan", "diff", "promote-status", "preview-plan", "previews", "preview-list", "preview-status"],
|
|
9321
9321
|
write: ["release", "upload", "configure", "transfer", "promote", "promote-reconcile", "deploy", "rollback", "reconcile", "retire", "preview", "preview-create", "preview-reconcile", "preview-cleanup"]
|
|
9322
9322
|
},
|
|
9323
9323
|
db: {
|
|
@@ -10955,6 +10955,65 @@ function parseDeploymentEvidence(value) {
|
|
|
10955
10955
|
}
|
|
10956
10956
|
return structuredClone(value);
|
|
10957
10957
|
}
|
|
10958
|
+
function formatDeploymentEvidence(evidence) {
|
|
10959
|
+
const report = summarizeDeploymentEvidence(evidence);
|
|
10960
|
+
return [
|
|
10961
|
+
`DEPLOYMENT ${evidence.scope.project_ref}/${evidence.scope.application_id}/${evidence.scope.environment_id}`,
|
|
10962
|
+
` status: ${report.status}`,
|
|
10963
|
+
...report.status !== evidence.status ? [` recorded status: ${evidence.status} (additional proof missing)`] : [],
|
|
10964
|
+
` manifest: ${evidence.source.manifest_sha256.slice(0, 12)}`,
|
|
10965
|
+
` database: ${evidence.database.provider} ${evidence.database.version} (${evidence.database.topology})`,
|
|
10966
|
+
` activation: ${evidence.activation.activation_id}`,
|
|
10967
|
+
` rollback: ${evidence.rollback.status}`,
|
|
10968
|
+
` health: ${evidence.health.status}; smoke=${evidence.health.authenticated_smoke}`,
|
|
10969
|
+
` release: ${evidence.activation.release_id}`,
|
|
10970
|
+
` recorded: ${evidence.recorded_at} (snapshot, not a live probe)`,
|
|
10971
|
+
...report.issues.map((issue) => ` ${issue.status} ${issue.component}: ${issue.nextAction}`),
|
|
10972
|
+
" recovery: code rollback does not restore database or object storage",
|
|
10973
|
+
" retries: do not replay mutations with unknown outcomes; read back the original identity"
|
|
10974
|
+
].join(`
|
|
10975
|
+
`);
|
|
10976
|
+
}
|
|
10977
|
+
function summarizeDeploymentEvidence(value) {
|
|
10978
|
+
const evidence = parseDeploymentEvidence(value);
|
|
10979
|
+
const issues = [];
|
|
10980
|
+
const observe = (component, state, complete, nextAction) => {
|
|
10981
|
+
if (state !== "confirmed" || !complete)
|
|
10982
|
+
issues.push({
|
|
10983
|
+
component,
|
|
10984
|
+
status: state === "failed" ? "failed" : state === "unknown" ? "unknown" : "incomplete",
|
|
10985
|
+
nextAction
|
|
10986
|
+
});
|
|
10987
|
+
};
|
|
10988
|
+
for (const component of evidence.components) {
|
|
10989
|
+
observe(component.name, component.status, component.checked_at !== null && component.health_check !== null, "Inspect this component's health and logs for the recorded activation before changing or restarting it.");
|
|
10990
|
+
}
|
|
10991
|
+
observe("database.migration", evidence.database.migration.status, evidence.database.migration.compatibility === "verified" && evidence.database.migration.inventory_sha256 !== null, "Read the migration inventory and compare its digest; do not reapply an unconfirmed migration.");
|
|
10992
|
+
observe("database.backup", evidence.database.backup.status, evidence.database.backup.latest_success_at !== null && evidence.database.backup.freshness_seconds !== null, "Read backup inventory and freshness for this project; a successful deployment is not a backup.");
|
|
10993
|
+
observe("database.recovery", evidence.database.recovery.status, evidence.database.recovery.drill_id !== null && evidence.database.recovery.rpo_seconds !== null && evidence.database.recovery.rto_seconds !== null, "Obtain a separate approved restore drill and verify data, permissions and storage; never restore automatically.");
|
|
10994
|
+
observe("health", evidence.health.status, evidence.health.checked_at !== null, "Read back this activation's readiness; process health alone does not prove a business operation.");
|
|
10995
|
+
observe("authenticated-smoke", evidence.health.authenticated_smoke, true, "Run the selected business smoke with a real authorized session and verify forbidden access remains denied.");
|
|
10996
|
+
observe("code-rollback", evidence.rollback.status === "ready" ? "confirmed" : evidence.rollback.status, evidence.rollback.release_id !== null && evidence.rollback.configuration_id !== null, "Inspect the explicit rollback release and configuration; data recovery requires a separate plan.");
|
|
10997
|
+
if (evidence.rollback.result !== null) {
|
|
10998
|
+
observe("code-rollback.result", evidence.rollback.result, true, "Read back the rollback activation and authenticated smoke before another mutation.");
|
|
10999
|
+
}
|
|
11000
|
+
return {
|
|
11001
|
+
version: 1,
|
|
11002
|
+
status: evidence.status === "confirmed" && issues.length > 0 ? "incomplete" : evidence.status,
|
|
11003
|
+
recordedStatus: evidence.status,
|
|
11004
|
+
scope: evidence.scope,
|
|
11005
|
+
recordedAt: evidence.recorded_at,
|
|
11006
|
+
correlation: {
|
|
11007
|
+
releaseId: evidence.activation.release_id,
|
|
11008
|
+
activationId: evidence.activation.activation_id,
|
|
11009
|
+
manifestSha256: evidence.source.manifest_sha256
|
|
11010
|
+
},
|
|
11011
|
+
issues,
|
|
11012
|
+
automaticMutationRetry: false,
|
|
11013
|
+
codeRollbackRestoresData: false,
|
|
11014
|
+
liveObservationPerformed: false
|
|
11015
|
+
};
|
|
11016
|
+
}
|
|
10958
11017
|
// node_modules/@supacloud/delivery/src/delivery-tuple.ts
|
|
10959
11018
|
var sha2563 = String2({ pattern: "^[a-f0-9]{64}$" });
|
|
10960
11019
|
var commit = String2({ pattern: "^(?:[a-f0-9]{40}|[a-f0-9]{64})$" });
|
|
@@ -21388,7 +21447,7 @@ var starter_metadata_default = {
|
|
|
21388
21447
|
generatedBy: "supacloud/starter-metadata-v1",
|
|
21389
21448
|
packages: {
|
|
21390
21449
|
compiler: {
|
|
21391
|
-
version: "0.46.
|
|
21450
|
+
version: "0.46.5"
|
|
21392
21451
|
},
|
|
21393
21452
|
app: {
|
|
21394
21453
|
version: "0.27.1",
|
|
@@ -21397,16 +21456,16 @@ var starter_metadata_default = {
|
|
|
21397
21456
|
}
|
|
21398
21457
|
},
|
|
21399
21458
|
elysia: {
|
|
21400
|
-
version: "0.
|
|
21459
|
+
version: "0.33.1"
|
|
21401
21460
|
},
|
|
21402
21461
|
commands: {
|
|
21403
21462
|
version: "0.9.3"
|
|
21404
21463
|
},
|
|
21405
21464
|
contracts: {
|
|
21406
|
-
version: "0.11.
|
|
21465
|
+
version: "0.11.1"
|
|
21407
21466
|
},
|
|
21408
21467
|
db: {
|
|
21409
|
-
version: "0.
|
|
21468
|
+
version: "0.18.0",
|
|
21410
21469
|
peerDependencies: {
|
|
21411
21470
|
"drizzle-orm": "1.0.0-rc.5-5935859"
|
|
21412
21471
|
},
|
|
@@ -22450,7 +22509,7 @@ export async function createReviewAttachmentAdapters(options: ReviewAttachmentOp
|
|
|
22450
22509
|
`;
|
|
22451
22510
|
|
|
22452
22511
|
// src/shared/tools/app-starter-attachment-worker.ts
|
|
22453
|
-
var STARTER_ATTACHMENT_WORKER = `import { createWorker, type CompiledModule } from "@supacloud/elysia";
|
|
22512
|
+
var STARTER_ATTACHMENT_WORKER = `import { createWorker, type CompiledModule, type ExecutionObserver } from "@supacloud/elysia";
|
|
22454
22513
|
import {
|
|
22455
22514
|
createSupaCloudWorkflowTransport,
|
|
22456
22515
|
type SupaCloudWorkflowClaim,
|
|
@@ -22464,6 +22523,7 @@ export interface ReviewAttachmentWorkerOptions extends ReviewAttachmentOptions {
|
|
|
22464
22523
|
workerId: string;
|
|
22465
22524
|
queueOwnership: "exclusive-review-attachments";
|
|
22466
22525
|
pollIntervalMs?: number;
|
|
22526
|
+
onExecution?: ExecutionObserver;
|
|
22467
22527
|
}
|
|
22468
22528
|
|
|
22469
22529
|
export async function createReviewAttachmentWorker(options: ReviewAttachmentWorkerOptions) {
|
|
@@ -22490,7 +22550,9 @@ export async function createReviewAttachmentWorker(options: ReviewAttachmentWork
|
|
|
22490
22550
|
throw new Error("Invalid attachment workflow input");
|
|
22491
22551
|
}
|
|
22492
22552
|
const bound = await options.database.transaction(tx => tx.query(
|
|
22493
|
-
"SELECT run_id FROM public.starter_attachments WHERE review_id=$1 AND artifact_id=$2
|
|
22553
|
+
"SELECT a.run_id FROM public.starter_attachments a WHERE a.review_id=$1 AND a.artifact_id=$2 " +
|
|
22554
|
+
"AND COALESCE((SELECT r.run_id FROM public.starter_attachment_retries r " +
|
|
22555
|
+
"WHERE r.review_id=a.review_id ORDER BY r.generation DESC LIMIT 1),a.run_id)=$3",
|
|
22494
22556
|
[reviewId, artifactId, claim.runId],
|
|
22495
22557
|
));
|
|
22496
22558
|
if (!Array.isArray(bound) || bound.length !== 1) throw new Error("Attachment workflow ownership mismatch");
|
|
@@ -22502,6 +22564,7 @@ export async function createReviewAttachmentWorker(options: ReviewAttachmentWork
|
|
|
22502
22564
|
modules: options.modules, deps: { dbClient: store }, workerId, concurrency: 1,
|
|
22503
22565
|
pollIntervalMs: options.pollIntervalMs ?? 1000,
|
|
22504
22566
|
onError: fatal,
|
|
22567
|
+
...(options.onExecution === undefined ? {} : { onExecution: options.onExecution }),
|
|
22505
22568
|
transport: {
|
|
22506
22569
|
async claim(signal): Promise<SupaCloudWorkflowClaim | null> {
|
|
22507
22570
|
if (halted || signal.aborted) return null;
|
|
@@ -22557,6 +22620,7 @@ import { SupaCloudWorkflowsClient } from "@supacloud/js";
|
|
|
22557
22620
|
import { createBunCommandDatabase } from "@supacloud/db/bun";
|
|
22558
22621
|
import type { CompiledModule } from "@supacloud/elysia";
|
|
22559
22622
|
import { createReviewAttachmentWorker } from "./host/review-attachment-worker";
|
|
22623
|
+
import { createReviewWorkerObserver } from "./host/review-worker-observability";
|
|
22560
22624
|
|
|
22561
22625
|
function required(name: string): string {
|
|
22562
22626
|
const value = process.env[name];
|
|
@@ -22600,6 +22664,7 @@ export async function createDeliveryWorker(modules: CompiledModule[], lifecycle:
|
|
|
22600
22664
|
database: createBunCommandDatabase(pool), service, projectId: settings.projectId,
|
|
22601
22665
|
tenantId: settings.tenantId, modules, workerId: settings.workerId,
|
|
22602
22666
|
workflows: new SupaCloudWorkflowsClient(service), queueOwnership: "exclusive-review-attachments",
|
|
22667
|
+
onExecution: createReviewWorkerObserver(settings.workerId),
|
|
22603
22668
|
});
|
|
22604
22669
|
lifecycle.signal.throwIfAborted();
|
|
22605
22670
|
return {
|
|
@@ -22616,15 +22681,50 @@ export async function createDeliveryWorker(modules: CompiledModule[], lifecycle:
|
|
|
22616
22681
|
}
|
|
22617
22682
|
`;
|
|
22618
22683
|
|
|
22684
|
+
// src/shared/tools/app-starter-execution-observability.ts
|
|
22685
|
+
var STARTER_EXECUTION_OBSERVABILITY = `import type { ExecutionObserver } from "@supacloud/elysia";
|
|
22686
|
+
|
|
22687
|
+
function correlationId(value: string | undefined): string | undefined {
|
|
22688
|
+
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value) ? value : undefined;
|
|
22689
|
+
}
|
|
22690
|
+
|
|
22691
|
+
function createObserver(source: "http" | "worker", workerId?: string): ExecutionObserver {
|
|
22692
|
+
return metadata => {
|
|
22693
|
+
// Select fields explicitly: future observer extensions must not leak payloads.
|
|
22694
|
+
console.info(JSON.stringify({
|
|
22695
|
+
event: source === "http" ? "review-http-execution" : "review-worker-execution",
|
|
22696
|
+
component: source === "http" ? "review-http" : "review-attachment-worker",
|
|
22697
|
+
timestamp: new Date().toISOString(),
|
|
22698
|
+
workerId: correlationId(workerId),
|
|
22699
|
+
kind: metadata.kind,
|
|
22700
|
+
operation: metadata.operation,
|
|
22701
|
+
stage: metadata.stage,
|
|
22702
|
+
phase: metadata.phase,
|
|
22703
|
+
requestId: correlationId(metadata.requestId),
|
|
22704
|
+
durationMs: typeof metadata.durationMs === "number" && Number.isFinite(metadata.durationMs)
|
|
22705
|
+
&& metadata.durationMs >= 0 ? metadata.durationMs : undefined,
|
|
22706
|
+
}));
|
|
22707
|
+
};
|
|
22708
|
+
}
|
|
22709
|
+
|
|
22710
|
+
export function createReviewHttpObserver(): ExecutionObserver {
|
|
22711
|
+
return createObserver("http");
|
|
22712
|
+
}
|
|
22713
|
+
|
|
22714
|
+
export function createReviewWorkerObserver(workerId: string): ExecutionObserver {
|
|
22715
|
+
return createObserver("worker", workerId);
|
|
22716
|
+
}
|
|
22717
|
+
`;
|
|
22718
|
+
var STARTER_WORKER_OBSERVABILITY = `export { createReviewWorkerObserver } from "./review-execution-observability";
|
|
22719
|
+
`;
|
|
22720
|
+
|
|
22619
22721
|
// src/shared/tools/app-starter-upload.ts
|
|
22620
22722
|
var STARTER_UPLOAD_FEATURE = `import { Effect } from "effect";
|
|
22621
22723
|
import { Body, Controller, Inject, Injectable, Param, Post, DB_CLIENT, REQUEST_CONTEXT } from "@supacloud/app";
|
|
22622
22724
|
import { ApplicationError } from "@supacloud/elysia";
|
|
22623
|
-
import { UploadBody, UploadLocation, UploadParams, UploadResult
|
|
22624
|
-
|
|
22625
|
-
export
|
|
22626
|
-
export interface ReviewUploadLocation { artifactId: string; bucketId: string; objectPath: string }
|
|
22627
|
-
export interface ReviewUploadResult { artifactId: string; runId: string; objectPath: string; sha256: string; bytes: number }
|
|
22725
|
+
import { UploadBody, UploadLocation, UploadParams, UploadResult,
|
|
22726
|
+
type ReviewUploadBody, type ReviewUploadInput, type ReviewUploadLocation, type ReviewUploadResult } from "./contracts";
|
|
22727
|
+
export type { ReviewUploadInput, ReviewUploadLocation, ReviewUploadResult } from "./contracts";
|
|
22628
22728
|
type PreviewUploadError =
|
|
22629
22729
|
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
22630
22730
|
| { readonly _tag: "UPLOAD_INPUT_INVALID" }
|
|
@@ -22639,6 +22739,7 @@ type RegisterUploadError =
|
|
|
22639
22739
|
export interface ReviewUploadPort {
|
|
22640
22740
|
previewUpload(context: unknown, input: ReviewUploadInput): Promise<ReviewUploadLocation>;
|
|
22641
22741
|
registerUpload(context: unknown, input: ReviewUploadInput): Promise<ReviewUploadResult>;
|
|
22742
|
+
lookupUpload?(context: unknown, input: ReviewUploadInput): Promise<boolean>;
|
|
22642
22743
|
}
|
|
22643
22744
|
@Injectable({ scope: "request" })
|
|
22644
22745
|
export class ReviewUploads {
|
|
@@ -22675,7 +22776,7 @@ export class ReviewUploadsController {
|
|
|
22675
22776
|
retry: "none",
|
|
22676
22777
|
},
|
|
22677
22778
|
})
|
|
22678
|
-
preview(@Param("id") id: string, @Body() body:
|
|
22779
|
+
preview(@Param("id") id: string, @Body() body: ReviewUploadBody): Effect.Effect<ReviewUploadLocation, PreviewUploadError, never> {
|
|
22679
22780
|
return Effect.promise(() => this.uploads.preview({ reviewId: id, artifactId: body.artifactId, expectedVersion: body.expectedVersion }));
|
|
22680
22781
|
}
|
|
22681
22782
|
@Post("/:id/attachment-registration", {
|
|
@@ -22698,7 +22799,7 @@ export class ReviewUploadsController {
|
|
|
22698
22799
|
retry: "none",
|
|
22699
22800
|
},
|
|
22700
22801
|
})
|
|
22701
|
-
register(@Param("id") id: string, @Body() body:
|
|
22802
|
+
register(@Param("id") id: string, @Body() body: ReviewUploadBody): Effect.Effect<ReviewUploadResult, RegisterUploadError, never> {
|
|
22702
22803
|
return Effect.promise(() => this.uploads.register({ reviewId: id, artifactId: body.artifactId, expectedVersion: body.expectedVersion }));
|
|
22703
22804
|
}
|
|
22704
22805
|
}
|
|
@@ -22750,7 +22851,8 @@ import { SupaCloudArtifactsClient } from "@supacloud/js";
|
|
|
22750
22851
|
import { createTransactionalCommand, plaintextCommandInput } from "@supacloud/commands";
|
|
22751
22852
|
import { CommandError } from "@supacloud/contracts";
|
|
22752
22853
|
import { createPostgresCommandStore, type CommandTransaction } from "@supacloud/db";
|
|
22753
|
-
import { ApplicationError, requireTrustedIdentity } from "@supacloud/elysia";
|
|
22854
|
+
import { ApplicationError, createSchemaDecoder, requireTrustedIdentity } from "@supacloud/elysia";
|
|
22855
|
+
import { UploadInput, UploadResult } from "../review/contracts";
|
|
22754
22856
|
import type { ReviewAttachmentOptions } from "./review-attachments";
|
|
22755
22857
|
import type { ReviewUploadInput, ReviewUploadPort, ReviewUploadResult } from "../review/uploads";
|
|
22756
22858
|
|
|
@@ -22765,20 +22867,15 @@ function rows(value: unknown): Record<string, unknown>[] {
|
|
|
22765
22867
|
if (!Array.isArray(value)) throw new TypeError("Invalid upload database result");
|
|
22766
22868
|
return value.map(record);
|
|
22767
22869
|
}
|
|
22870
|
+
const decodeInput = createSchemaDecoder(UploadInput, { normalize: false });
|
|
22871
|
+
const result = createSchemaDecoder(UploadResult, { normalize: false });
|
|
22768
22872
|
function capture(value: unknown): ReviewUploadInput {
|
|
22769
|
-
|
|
22770
|
-
|
|
22771
|
-
|
|
22772
|
-
|
|
22873
|
+
try {
|
|
22874
|
+
const input = decodeInput(value);
|
|
22875
|
+
return { ...input, reviewId: input.reviewId.toLowerCase(), artifactId: input.artifactId.toLowerCase() };
|
|
22876
|
+
} catch {
|
|
22773
22877
|
throw new ApplicationError("Invalid upload input", { status: 400, code: "UPLOAD_INPUT_INVALID" });
|
|
22774
22878
|
}
|
|
22775
|
-
return { reviewId: item["reviewId"].toLowerCase(), artifactId: item["artifactId"].toLowerCase(), expectedVersion: item["expectedVersion"] };
|
|
22776
|
-
}
|
|
22777
|
-
function result(value: unknown): ReviewUploadResult {
|
|
22778
|
-
const item = record(value);
|
|
22779
|
-
if (typeof item["artifactId"] !== "string" || typeof item["runId"] !== "string" || typeof item["objectPath"] !== "string"
|
|
22780
|
-
|| typeof item["sha256"] !== "string" || typeof item["bytes"] !== "number") throw new TypeError("Invalid upload receipt");
|
|
22781
|
-
return { artifactId: item["artifactId"], runId: item["runId"], objectPath: item["objectPath"], sha256: item["sha256"], bytes: item["bytes"] };
|
|
22782
22879
|
}
|
|
22783
22880
|
|
|
22784
22881
|
export async function createReviewUploadAdapters(options: ReviewAttachmentOptions): Promise<ReviewUploadPort> {
|
|
@@ -22805,7 +22902,7 @@ export async function createReviewUploadAdapters(options: ReviewAttachmentOption
|
|
|
22805
22902
|
"SELECT m.storage_subject::text AS storage_subject FROM public.starter_reviews r " +
|
|
22806
22903
|
"JOIN public.starter_members m ON m.subject=r.owner_id CROSS JOIN public.starter_application p " +
|
|
22807
22904
|
"WHERE r.id=$1 AND r.owner_id=$2 AND ((r.state='draft' AND r.version=$3) OR " +
|
|
22808
|
-
"($6::boolean AND r.state='approved' AND r.version=$3+1 AND EXISTS (" +
|
|
22905
|
+
"($6::boolean AND r.state='approved' AND r.version=$3::bigint+1 AND EXISTS (" +
|
|
22809
22906
|
"SELECT 1 FROM public.starter_attachments b WHERE b.review_id=r.id AND b.artifact_id=$7::uuid AND b.owner_id=$2 " +
|
|
22810
22907
|
"AND b.object_path=m.storage_subject::text || '/' || r.id || '/' || $7::text || '.txt'))) " +
|
|
22811
22908
|
"AND m.enabled AND m.can_approve AND m.storage_subject IS NOT NULL " +
|
|
@@ -22817,7 +22914,24 @@ export async function createReviewUploadAdapters(options: ReviewAttachmentOption
|
|
|
22817
22914
|
}
|
|
22818
22915
|
return allowed[0]["storage_subject"] + "/" + input.reviewId + "/" + input.artifactId + ".txt";
|
|
22819
22916
|
}
|
|
22917
|
+
const receiptLookup = createTransactionalCommand({
|
|
22918
|
+
name: "review.attach", store: createPostgresCommandStore(database), inputCodec: plaintextCommandInput,
|
|
22919
|
+
input: capture, result,
|
|
22920
|
+
async authorize(who, request, tx) {
|
|
22921
|
+
try { await authorize(tx, who.actorId, request, true); return "allow"; }
|
|
22922
|
+
catch (error) {
|
|
22923
|
+
if (error instanceof ApplicationError && error.code === "UPLOAD_DENIED") return "deny";
|
|
22924
|
+
throw error;
|
|
22925
|
+
}
|
|
22926
|
+
},
|
|
22927
|
+
execute: async () => { throw new Error("Receipt lookup cannot register an upload"); },
|
|
22928
|
+
audit: { event: "review.attachment-bound", details: request => ({ reviewId: request.reviewId, artifactId: request.artifactId }) },
|
|
22929
|
+
});
|
|
22820
22930
|
return {
|
|
22931
|
+
async lookupUpload(context, value) {
|
|
22932
|
+
const input = capture(value), subject = actor(context);
|
|
22933
|
+
return (await receiptLookup.lookup({ actorId: subject, tenantId }, input.artifactId, input))?.status === "confirmed";
|
|
22934
|
+
},
|
|
22821
22935
|
async previewUpload(context, value) {
|
|
22822
22936
|
const input = capture(value), subject = actor(context);
|
|
22823
22937
|
const objectPath = await database.transaction(tx => authorize(tx, subject, input));
|
|
@@ -22825,8 +22939,14 @@ export async function createReviewUploadAdapters(options: ReviewAttachmentOption
|
|
|
22825
22939
|
},
|
|
22826
22940
|
async registerUpload(context, value) {
|
|
22827
22941
|
const input = capture(value), subject = actor(context);
|
|
22828
|
-
const objectPath = await database.transaction(tx => authorize(tx, subject, input, true));
|
|
22829
22942
|
try {
|
|
22943
|
+
// Only an authorized, validated receipt can bypass external registration.
|
|
22944
|
+
const saved = await receiptLookup.lookup({ actorId: subject, tenantId }, input.artifactId, input);
|
|
22945
|
+
if (saved !== null) {
|
|
22946
|
+
if (saved.status !== "confirmed") throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
22947
|
+
return saved.result;
|
|
22948
|
+
}
|
|
22949
|
+
const objectPath = await database.transaction(tx => authorize(tx, subject, input, true));
|
|
22830
22950
|
const downloaded = await service.storage.from("review-attachments").download(objectPath);
|
|
22831
22951
|
if (downloaded.error || !downloaded.data) throw new Error("Upload unavailable");
|
|
22832
22952
|
const blob = downloaded.data;
|
|
@@ -22984,21 +23104,24 @@ ALTER TABLE public.starter_application ENABLE ROW LEVEL SECURITY;
|
|
|
22984
23104
|
ALTER TABLE public.starter_reviews ENABLE ROW LEVEL SECURITY;
|
|
22985
23105
|
ALTER TABLE public.starter_members ENABLE ROW LEVEL SECURITY;
|
|
22986
23106
|
`;
|
|
22987
|
-
var STARTER_REVIEW_POSTGRES = `import { plaintextCommandInput } from "@supacloud/commands";
|
|
23107
|
+
var STARTER_REVIEW_POSTGRES = `import { createTransactionalCommand, plaintextCommandInput } from "@supacloud/commands";
|
|
22988
23108
|
import { CommandError } from "@supacloud/contracts";
|
|
22989
23109
|
import { createPostgresCommandStore, type CommandDatabase, type CommandTransaction } from "@supacloud/db";
|
|
22990
23110
|
import {
|
|
22991
23111
|
ApplicationError, createSchemaDecoder, createSupAuthRequestContext, requireTrustedIdentity,
|
|
22992
23112
|
createTransactionalCommandGovernance,
|
|
22993
|
-
type
|
|
23113
|
+
type SupAuthContextOptions,
|
|
22994
23114
|
} from "@supacloud/elysia";
|
|
22995
23115
|
import type { AppAdapters } from "../application";
|
|
22996
23116
|
import type { ReviewStore } from "../review/review";
|
|
22997
23117
|
import type { ReviewUploadPort } from "../review/uploads";
|
|
23118
|
+
import type { ReviewTaskPort } from "../review/tasks";
|
|
22998
23119
|
import { ApprovalInput, ReviewResult } from "../review/contracts";
|
|
23120
|
+
import type { ReviewRow } from "../../db/schema";
|
|
22999
23121
|
|
|
23000
23122
|
const input = createSchemaDecoder(ApprovalInput, { normalize: false });
|
|
23001
|
-
const result
|
|
23123
|
+
const result: (value: unknown) => Pick<ReviewRow, "state" | "version"> =
|
|
23124
|
+
createSchemaDecoder(ReviewResult, { normalize: false });
|
|
23002
23125
|
|
|
23003
23126
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23004
23127
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -23020,6 +23143,7 @@ export interface ReviewPostgresOptions {
|
|
|
23020
23143
|
tenantId: string;
|
|
23021
23144
|
identity: Omit<SupAuthContextOptions, "resolveAccess">;
|
|
23022
23145
|
uploads?: ReviewUploadPort;
|
|
23146
|
+
tasks?: ReviewTaskPort;
|
|
23023
23147
|
// Only database effects here: they commit/rollback with the approval and receipt.
|
|
23024
23148
|
afterApproved?(tx: CommandTransaction, id: string, version: number): Promise<void>;
|
|
23025
23149
|
}
|
|
@@ -23055,6 +23179,38 @@ export async function createReviewPostgresAdapters(options: ReviewPostgresOption
|
|
|
23055
23179
|
const transaction = () => commandBinding.transaction();
|
|
23056
23180
|
const store: ReviewStore = {
|
|
23057
23181
|
...options.uploads,
|
|
23182
|
+
...options.tasks,
|
|
23183
|
+
async lookupOperation(context, operation) {
|
|
23184
|
+
const subject = actor(context);
|
|
23185
|
+
try {
|
|
23186
|
+
let confirmed: boolean = false;
|
|
23187
|
+
if (operation.kind === "upload") {
|
|
23188
|
+
if (!options.uploads?.lookupUpload) {
|
|
23189
|
+
throw new ApplicationError("Upload receipt lookup unavailable", { status: 503, code: "RECEIPTS_UNAVAILABLE" });
|
|
23190
|
+
}
|
|
23191
|
+
confirmed = await options.uploads.lookupUpload(context, {
|
|
23192
|
+
reviewId: operation.reviewId, artifactId: operation.operationId, expectedVersion: operation.expectedVersion,
|
|
23193
|
+
});
|
|
23194
|
+
} else {
|
|
23195
|
+
const command = createTransactionalCommand({
|
|
23196
|
+
name: "review.approve", store: commandStore, inputCodec: plaintextCommandInput, input, result,
|
|
23197
|
+
authorize: authorizeApproval,
|
|
23198
|
+
execute: async () => { throw new Error("Receipt lookup cannot execute a command"); },
|
|
23199
|
+
audit: { event: "review.approved", details: request => ({ reviewId: request.id }) },
|
|
23200
|
+
});
|
|
23201
|
+
const receipt = await command.lookup({ actorId: subject, tenantId }, operation.operationId,
|
|
23202
|
+
{ id: operation.reviewId, expectedVersion: operation.expectedVersion });
|
|
23203
|
+
confirmed = receipt?.status === "confirmed";
|
|
23204
|
+
}
|
|
23205
|
+
return { status: confirmed ? "confirmed" : "unknown", operationId: operation.operationId, reviewId: operation.reviewId };
|
|
23206
|
+
} catch (error) {
|
|
23207
|
+
if (error instanceof ApplicationError) throw error;
|
|
23208
|
+
if (error instanceof CommandError && ["COMMAND_REJECTED", "COMMAND_IDEMPOTENCY_CONFLICT"].includes(error.code)) {
|
|
23209
|
+
throw new ApplicationError(error.code, { code: error.code, status: error.code === "COMMAND_REJECTED" ? 403 : 409 });
|
|
23210
|
+
}
|
|
23211
|
+
throw new ApplicationError("Receipt lookup unavailable", { status: 503, code: "RECEIPTS_UNAVAILABLE" });
|
|
23212
|
+
}
|
|
23213
|
+
},
|
|
23058
23214
|
async get(table, id) {
|
|
23059
23215
|
if (table !== "reviews") throw new Error("Unsupported review table");
|
|
23060
23216
|
return rows(await transaction().query(
|
|
@@ -23072,35 +23228,37 @@ export async function createReviewPostgresAdapters(options: ReviewPostgresOption
|
|
|
23072
23228
|
await afterApproved?.(transaction(), id, review.version);
|
|
23073
23229
|
},
|
|
23074
23230
|
};
|
|
23075
|
-
function actor(
|
|
23076
|
-
const who = requireTrustedIdentity(
|
|
23077
|
-
const access = record(record(
|
|
23231
|
+
function actor(context: unknown): string {
|
|
23232
|
+
const who = requireTrustedIdentity(context);
|
|
23233
|
+
const access = record(record(context)["access"]);
|
|
23078
23234
|
if (access["projectId"] !== projectId || access["tenantId"] !== tenantId
|
|
23079
23235
|
|| !Array.isArray(access["permissions"]) || !access["permissions"].includes("review.approve")) {
|
|
23080
23236
|
throw new ApplicationError("Command permission denied", { status: 403, code: "REVIEW_PERMISSION_DENIED" });
|
|
23081
23237
|
}
|
|
23082
23238
|
return who.subject;
|
|
23083
23239
|
}
|
|
23240
|
+
const commandStore = createPostgresCommandStore(database);
|
|
23241
|
+
async function authorizeApproval(who: { actorId: string; tenantId: string }, request: ReturnType<typeof input>, tx: CommandTransaction): Promise<"allow" | "deny"> {
|
|
23242
|
+
const found = rows(await tx.query(
|
|
23243
|
+
"SELECT r.id FROM public.starter_reviews r JOIN public.starter_members m ON m.subject=r.owner_id " +
|
|
23244
|
+
"CROSS JOIN public.starter_application a " +
|
|
23245
|
+
"WHERE r.id=$1 AND r.owner_id=$2 AND m.enabled AND m.can_approve " +
|
|
23246
|
+
"AND a.singleton AND a.project_id=$3 AND a.tenant_id=$4 FOR UPDATE OF r,m FOR SHARE OF a",
|
|
23247
|
+
[request.id, who.actorId, projectId, who.tenantId],
|
|
23248
|
+
));
|
|
23249
|
+
return found.length === 1 ? "allow" : "deny";
|
|
23250
|
+
}
|
|
23084
23251
|
const commandBinding = createTransactionalCommandGovernance({
|
|
23085
23252
|
permission: "review.approve",
|
|
23086
|
-
identity: invocation => ({ actorId: actor(invocation), tenantId }),
|
|
23253
|
+
identity: invocation => ({ actorId: actor(invocation.requestContext), tenantId }),
|
|
23087
23254
|
input: invocation => ({
|
|
23088
23255
|
...record(invocation.input.body), id: invocation.input.params["id"],
|
|
23089
23256
|
}),
|
|
23090
23257
|
rejection: error => isPublicConflict(error) ? new CommandError("COMMAND_IDEMPOTENCY_CONFLICT") : undefined,
|
|
23091
23258
|
definition: {
|
|
23092
|
-
name: "review.approve", store:
|
|
23259
|
+
name: "review.approve", store: commandStore,
|
|
23093
23260
|
inputCodec: plaintextCommandInput, input, result,
|
|
23094
|
-
|
|
23095
|
-
const found = rows(await tx.query(
|
|
23096
|
-
"SELECT r.id FROM public.starter_reviews r JOIN public.starter_members m ON m.subject=r.owner_id " +
|
|
23097
|
-
"CROSS JOIN public.starter_application a " +
|
|
23098
|
-
"WHERE r.id=$1 AND r.owner_id=$2 AND m.enabled AND m.can_approve " +
|
|
23099
|
-
"AND a.singleton AND a.project_id=$3 AND a.tenant_id=$4 FOR UPDATE OF r,m FOR SHARE OF a",
|
|
23100
|
-
[request.id, who.actorId, projectId, who.tenantId],
|
|
23101
|
-
));
|
|
23102
|
-
return found.length === 1 ? "allow" : "deny";
|
|
23103
|
-
},
|
|
23261
|
+
authorize: authorizeApproval,
|
|
23104
23262
|
audit: { event: "review.approved", details: request => ({ reviewId: request.id }) },
|
|
23105
23263
|
},
|
|
23106
23264
|
});
|
|
@@ -23154,6 +23312,8 @@ import { createApplication, type CompiledModule } from "@supacloud/elysia";
|
|
|
23154
23312
|
import { createReviewPostgresAdapters } from "./host/review-postgres";
|
|
23155
23313
|
import { createReviewAttachmentAdapters } from "./host/review-attachments";
|
|
23156
23314
|
import { createReviewUploadAdapters } from "./host/review-uploads";
|
|
23315
|
+
import { createReviewTaskAdapters } from "./host/review-tasks";
|
|
23316
|
+
import { createReviewHttpObserver } from "./host/review-execution-observability";
|
|
23157
23317
|
|
|
23158
23318
|
function required(name: string): string {
|
|
23159
23319
|
const value = process.env[name];
|
|
@@ -23204,15 +23364,17 @@ export async function createDeliveryApplication(modules: CompiledModule[], lifec
|
|
|
23204
23364
|
const durable = attachmentOptions ? await createReviewAttachmentAdapters(attachmentOptions) : undefined;
|
|
23205
23365
|
stage = "uploads";
|
|
23206
23366
|
const uploads = attachmentOptions ? await createReviewUploadAdapters(attachmentOptions) : undefined;
|
|
23367
|
+
const tasks = attachmentOptions ? await createReviewTaskAdapters(attachmentOptions) : undefined;
|
|
23207
23368
|
stage = "postgres";
|
|
23208
23369
|
const adapters = await createReviewPostgresAdapters({
|
|
23209
23370
|
database, tenantId: settings.tenantId, identity: settings.identity,
|
|
23210
23371
|
...(uploads ? { uploads } : {}),
|
|
23372
|
+
...(tasks ? { tasks } : {}),
|
|
23211
23373
|
...(durable ? { afterApproved: durable.enqueue } : {}),
|
|
23212
23374
|
});
|
|
23213
23375
|
lifecycle.signal.throwIfAborted();
|
|
23214
23376
|
stage = "application";
|
|
23215
|
-
const app = createApplication({ name: "review", modules, ...adapters });
|
|
23377
|
+
const app = createApplication({ name: "review", modules, ...adapters, onExecution: createReviewHttpObserver() });
|
|
23216
23378
|
return { fetch: (request: Request) => app.handle(request), close };
|
|
23217
23379
|
} catch (error) {
|
|
23218
23380
|
await close();
|
|
@@ -23238,7 +23400,7 @@ var STARTER_DATABASE_CONFIG = {
|
|
|
23238
23400
|
role: "starter_review_http"
|
|
23239
23401
|
};
|
|
23240
23402
|
var STARTER_DATABASE_SCHEMA = `import { sql } from "drizzle-orm";
|
|
23241
|
-
import { boolean, check, integer, jsonb, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core";
|
|
23403
|
+
import { boolean, check, integer, jsonb, pgTable, primaryKey, text, unique, uuid } from "drizzle-orm/pg-core";
|
|
23242
23404
|
|
|
23243
23405
|
// Structure for the reviewed starter migrations. Grants/policies stay in those migrations.
|
|
23244
23406
|
export const application = pgTable.withRLS("starter_application", {
|
|
@@ -23286,12 +23448,23 @@ export const attachmentResults = pgTable.withRLS("starter_attachment_results", {
|
|
|
23286
23448
|
check("starter_attachment_results_version_check", sql\`\${table.version} >= 2\`),
|
|
23287
23449
|
]);
|
|
23288
23450
|
|
|
23451
|
+
export const attachmentRetries = pgTable.withRLS("starter_attachment_retries", {
|
|
23452
|
+
runId: uuid("run_id").primaryKey(),
|
|
23453
|
+
reviewId: text("review_id").notNull().references(() => attachments.reviewId),
|
|
23454
|
+
previousRunId: uuid("previous_run_id").notNull().unique(),
|
|
23455
|
+
generation: integer().notNull(),
|
|
23456
|
+
}, table => [
|
|
23457
|
+
unique("starter_attachment_retries_review_id_generation_key").on(table.reviewId, table.generation),
|
|
23458
|
+
check("starter_attachment_retries_generation_check", sql\`\${table.generation} > 0\`),
|
|
23459
|
+
check("starter_attachment_retries_check", sql\`\${table.runId} <> \${table.previousRunId}\`),
|
|
23460
|
+
]);
|
|
23461
|
+
|
|
23289
23462
|
export type ReviewRow = typeof reviews.$inferSelect;
|
|
23290
23463
|
export type NewReview = typeof reviews.$inferInsert;
|
|
23291
23464
|
`;
|
|
23292
23465
|
var STARTER_DATABASE_MODULES = `import { defineDatabaseModule } from "@supacloud/db";
|
|
23293
23466
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
23294
|
-
import { application, reviews, members, attachments, attachmentResults } from "./schema";
|
|
23467
|
+
import { application, reviews, members, attachments, attachmentResults, attachmentRetries } from "./schema";
|
|
23295
23468
|
|
|
23296
23469
|
const backendPolicies = [
|
|
23297
23470
|
["starter_backend_binding_read", "public.starter_application", "select", ["starter_review_http", "starter_review_worker"]],
|
|
@@ -23314,11 +23487,19 @@ const backendPolicies = [
|
|
|
23314
23487
|
// The module is the single live-Catalog ownership contract for starter tables.
|
|
23315
23488
|
export default defineDatabaseModule({
|
|
23316
23489
|
name: "starter",
|
|
23317
|
-
tables: [application, reviews, members, attachments, attachmentResults].map(table => {
|
|
23490
|
+
tables: [application, reviews, members, attachments, attachmentResults, attachmentRetries].map(table => {
|
|
23318
23491
|
const config = getTableConfig(table);
|
|
23319
23492
|
return (config.schema ?? "public") + "." + config.name;
|
|
23320
23493
|
}),
|
|
23321
23494
|
policies: [
|
|
23495
|
+
...([
|
|
23496
|
+
["starter_http_retry_read", "select", "starter_review_http"],
|
|
23497
|
+
["starter_http_retry_insert", "insert", "starter_review_http"],
|
|
23498
|
+
["starter_worker_retry_read", "select", "starter_review_worker"],
|
|
23499
|
+
] as const).map(([name, operation, role]) => ({
|
|
23500
|
+
name, table: "public.starter_attachment_retries", operation, roles: [role],
|
|
23501
|
+
source: "migrations/005-review-task-recovery.sql", tests: ["tests/database-governance.sql"],
|
|
23502
|
+
})),
|
|
23322
23503
|
...backendPolicies.map(([name, table, operation, roles]) => ({
|
|
23323
23504
|
name, table, operation, roles: [...roles], source: "migrations/004-review-runtime-roles.sql",
|
|
23324
23505
|
tests: ["tests/database-governance.sql"],
|
|
@@ -23341,15 +23522,15 @@ BEGIN READ ONLY;
|
|
|
23341
23522
|
DO $test$
|
|
23342
23523
|
DECLARE
|
|
23343
23524
|
owned text[] := ARRAY['starter_application','starter_reviews','starter_members',
|
|
23344
|
-
'starter_attachments','starter_attachment_results'];
|
|
23525
|
+
'starter_attachments','starter_attachment_results','starter_attachment_retries'];
|
|
23345
23526
|
BEGIN
|
|
23346
23527
|
IF (SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
23347
|
-
WHERE n.nspname='public' AND c.relname=ANY(owned) AND c.relrowsecurity) <>
|
|
23528
|
+
WHERE n.nspname='public' AND c.relname=ANY(owned) AND c.relrowsecurity) <> 6 THEN
|
|
23348
23529
|
RAISE EXCEPTION 'All starter tables must exist with RLS enabled';
|
|
23349
23530
|
END IF;
|
|
23350
23531
|
IF (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid=p.polrelid
|
|
23351
23532
|
JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
23352
|
-
WHERE n.nspname='public' AND c.relname=ANY(owned)) <>
|
|
23533
|
+
WHERE n.nspname='public' AND c.relname=ANY(owned)) <> 20 THEN
|
|
23353
23534
|
RAISE EXCEPTION 'Starter policy inventory changed; reconcile db/modules.ts';
|
|
23354
23535
|
END IF;
|
|
23355
23536
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname IN ('starter_review_http','starter_review_worker')
|
|
@@ -23429,6 +23610,831 @@ export default defineConfig({
|
|
|
23429
23610
|
};
|
|
23430
23611
|
}
|
|
23431
23612
|
|
|
23613
|
+
// src/shared/tools/app-starter-client.ts
|
|
23614
|
+
var STARTER_REVIEW_CLIENT = String.raw`import type { ReviewOperation, ReviewReceipt } from "../review/contracts";
|
|
23615
|
+
import { REVIEW_VERSION_MAX } from "../review/limits";
|
|
23616
|
+
export type { ReviewOperation } from "../review/contracts";
|
|
23617
|
+
|
|
23618
|
+
export interface ReviewRow { id: string; state: "draft" | "approved"; version: number }
|
|
23619
|
+
export type ReviewWriteResult =
|
|
23620
|
+
| (ReviewReceipt & { status: "confirmed" })
|
|
23621
|
+
| { status: "denied" | "conflict" | "unknown" };
|
|
23622
|
+
export interface ReviewTransport {
|
|
23623
|
+
list(): Promise<ReviewRow[]>;
|
|
23624
|
+
approve(operation: ReviewOperation): Promise<ReviewWriteResult>;
|
|
23625
|
+
upload(operation: ReviewOperation, file: Blob): Promise<ReviewWriteResult>;
|
|
23626
|
+
// Read-only receipt lookup, bound to current identity. A current row alone is not a receipt.
|
|
23627
|
+
lookup?(operation: ReviewOperation): Promise<ReviewWriteResult>;
|
|
23628
|
+
}
|
|
23629
|
+
export interface ReviewClientState {
|
|
23630
|
+
list: { status: "idle" | "loading" | "ready" | "empty" | "error" | "denied"; rows: ReviewRow[] };
|
|
23631
|
+
mutation: {
|
|
23632
|
+
status: "idle" | "submitting" | "confirmed" | "denied" | "conflict" | "unknown";
|
|
23633
|
+
operation: ReviewOperation | null;
|
|
23634
|
+
};
|
|
23635
|
+
}
|
|
23636
|
+
|
|
23637
|
+
export interface ReviewPendingStore {
|
|
23638
|
+
load(): ReviewOperation | null;
|
|
23639
|
+
save(operation: ReviewOperation): void;
|
|
23640
|
+
clear(operation: ReviewOperation): void;
|
|
23641
|
+
}
|
|
23642
|
+
|
|
23643
|
+
/** Keep operation identity, not tokens, file bytes or row data. Storage is not authorization. */
|
|
23644
|
+
export function createReviewPendingStore(storage: Pick<Storage, "getItem" | "setItem" | "removeItem">,
|
|
23645
|
+
scope: { projectId: string; tenantId: string; applicationId: string; subject: string }): ReviewPendingStore {
|
|
23646
|
+
const identity = [scope.projectId, scope.tenantId, scope.applicationId, scope.subject];
|
|
23647
|
+
if (identity.some(value => !value.trim())) throw new Error("Explicit recovery scope required");
|
|
23648
|
+
const key = "supacloud.review.pending.v1:" + JSON.stringify(identity);
|
|
23649
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23650
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23651
|
+
}
|
|
23652
|
+
function decode(value: unknown): ReviewOperation {
|
|
23653
|
+
if (!isRecord(value)) throw new Error("Invalid pending operation");
|
|
23654
|
+
const item = value;
|
|
23655
|
+
if ((item["kind"] !== "approve" && item["kind"] !== "upload")
|
|
23656
|
+
|| typeof item["reviewId"] !== "string" || !item["reviewId"].trim()
|
|
23657
|
+
|| typeof item["operationId"] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(item["operationId"])
|
|
23658
|
+
|| typeof item["expectedVersion"] !== "number" || !Number.isSafeInteger(item["expectedVersion"])
|
|
23659
|
+
|| item["expectedVersion"] < 1 || item["expectedVersion"] > REVIEW_VERSION_MAX) {
|
|
23660
|
+
throw new Error("Invalid pending operation");
|
|
23661
|
+
}
|
|
23662
|
+
return { kind: item["kind"], reviewId: item["reviewId"], operationId: item["operationId"], expectedVersion: item["expectedVersion"] };
|
|
23663
|
+
}
|
|
23664
|
+
const load = () => {
|
|
23665
|
+
const value = storage.getItem(key);
|
|
23666
|
+
if (value === null) return null;
|
|
23667
|
+
const parsed: unknown = JSON.parse(value);
|
|
23668
|
+
return decode(parsed);
|
|
23669
|
+
};
|
|
23670
|
+
return {
|
|
23671
|
+
load,
|
|
23672
|
+
save(operation) {
|
|
23673
|
+
const previous = load();
|
|
23674
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(decode(operation))) {
|
|
23675
|
+
throw new Error("Another unresolved operation exists");
|
|
23676
|
+
}
|
|
23677
|
+
storage.setItem(key, JSON.stringify(decode(operation)));
|
|
23678
|
+
},
|
|
23679
|
+
clear(operation) {
|
|
23680
|
+
const previous = load();
|
|
23681
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(decode(operation))) {
|
|
23682
|
+
throw new Error("Pending operation identity changed");
|
|
23683
|
+
}
|
|
23684
|
+
storage.removeItem(key);
|
|
23685
|
+
},
|
|
23686
|
+
};
|
|
23687
|
+
}
|
|
23688
|
+
|
|
23689
|
+
export function createReviewClient(transport: ReviewTransport, pending?: ReviewPendingStore) {
|
|
23690
|
+
let state: ReviewClientState = { list: { status: "idle", rows: [] }, mutation: { status: "idle", operation: null } };
|
|
23691
|
+
try {
|
|
23692
|
+
const operation = pending?.load();
|
|
23693
|
+
if (operation) state.mutation = { status: "unknown", operation };
|
|
23694
|
+
} catch { state.mutation = { status: "unknown", operation: null }; }
|
|
23695
|
+
let generation: number = 0, closed: boolean = false, recovering: boolean = false;
|
|
23696
|
+
const listeners = new Set<() => void>();
|
|
23697
|
+
const snapshot = (): ReviewClientState => structuredClone(state);
|
|
23698
|
+
const notify = () => {
|
|
23699
|
+
for (const listener of listeners) {
|
|
23700
|
+
// A view subscriber cannot interrupt persistence or change a write outcome.
|
|
23701
|
+
try { listener(); } catch {}
|
|
23702
|
+
}
|
|
23703
|
+
};
|
|
23704
|
+
function finish(operation: ReviewOperation, result: ReviewWriteResult) {
|
|
23705
|
+
if (closed) return;
|
|
23706
|
+
let status = result.status === "confirmed"
|
|
23707
|
+
&& (result.operationId !== operation.operationId || result.reviewId !== operation.reviewId)
|
|
23708
|
+
? "unknown" : result.status;
|
|
23709
|
+
if (status !== "unknown") {
|
|
23710
|
+
try {
|
|
23711
|
+
if (pending) {
|
|
23712
|
+
pending.clear(structuredClone(operation));
|
|
23713
|
+
if (pending.load() !== null) throw new Error("Pending operation was not cleared");
|
|
23714
|
+
}
|
|
23715
|
+
} catch { status = "unknown"; }
|
|
23716
|
+
}
|
|
23717
|
+
state = { ...state, mutation: { status, operation } };
|
|
23718
|
+
notify();
|
|
23719
|
+
}
|
|
23720
|
+
async function load() {
|
|
23721
|
+
if (closed) return;
|
|
23722
|
+
const request = ++generation;
|
|
23723
|
+
state = { ...state, list: { status: "loading", rows: [] } };
|
|
23724
|
+
notify();
|
|
23725
|
+
try {
|
|
23726
|
+
const rows = await transport.list();
|
|
23727
|
+
if (closed || request !== generation) return;
|
|
23728
|
+
state = { ...state, list: { status: rows.length ? "ready" : "empty", rows: structuredClone(rows) } };
|
|
23729
|
+
} catch (error) {
|
|
23730
|
+
if (closed || request !== generation) return;
|
|
23731
|
+
state = { ...state, list: { status: error instanceof ReviewAccessDenied ? "denied" : "error", rows: [] } };
|
|
23732
|
+
}
|
|
23733
|
+
notify();
|
|
23734
|
+
}
|
|
23735
|
+
async function submit(kind: ReviewOperation["kind"], row: ReviewRow, file?: Blob) {
|
|
23736
|
+
if (closed || recovering || state.mutation.status === "submitting" || state.mutation.status === "unknown") return;
|
|
23737
|
+
if (row.state !== "draft" || !Number.isSafeInteger(row.version) || row.version < 1 || !row.id.trim()) return;
|
|
23738
|
+
if (row.version > REVIEW_VERSION_MAX || (kind === "approve" && row.version === REVIEW_VERSION_MAX)) return;
|
|
23739
|
+
// Upload contracts are narrower than approval/list IDs and PostgreSQL-safe versions.
|
|
23740
|
+
// Reject locally before creating an unresolved operation for an impossible request.
|
|
23741
|
+
if (kind === "upload" && (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(row.id)
|
|
23742
|
+
|| !file || file.size < 1 || file.size > 1048576
|
|
23743
|
+
|| file.type.split(";")[0]?.trim().toLowerCase() !== "text/plain")) return;
|
|
23744
|
+
const operation: ReviewOperation = {
|
|
23745
|
+
kind, reviewId: row.id, expectedVersion: row.version, operationId: crypto.randomUUID(),
|
|
23746
|
+
};
|
|
23747
|
+
try {
|
|
23748
|
+
if (pending) {
|
|
23749
|
+
pending.save(structuredClone(operation));
|
|
23750
|
+
const stored = pending.load();
|
|
23751
|
+
if (!stored || stored.kind !== operation.kind || stored.reviewId !== operation.reviewId
|
|
23752
|
+
|| stored.expectedVersion !== operation.expectedVersion || stored.operationId !== operation.operationId) {
|
|
23753
|
+
throw new Error("Pending operation was not persisted");
|
|
23754
|
+
}
|
|
23755
|
+
}
|
|
23756
|
+
} catch {
|
|
23757
|
+
let original: ReviewOperation | null = null;
|
|
23758
|
+
try { original = pending?.load() ?? null; } catch {}
|
|
23759
|
+
state = { ...state, mutation: { status: "unknown", operation: original } };
|
|
23760
|
+
notify();
|
|
23761
|
+
return;
|
|
23762
|
+
}
|
|
23763
|
+
state = { ...state, mutation: { status: "submitting", operation } };
|
|
23764
|
+
notify();
|
|
23765
|
+
if (closed) return;
|
|
23766
|
+
let result: ReviewWriteResult;
|
|
23767
|
+
try {
|
|
23768
|
+
result = kind === "approve" ? await transport.approve(structuredClone(operation))
|
|
23769
|
+
: file ? await transport.upload(structuredClone(operation), file) : { status: "unknown" };
|
|
23770
|
+
} catch {
|
|
23771
|
+
result = { status: "unknown" };
|
|
23772
|
+
}
|
|
23773
|
+
finish(operation, result);
|
|
23774
|
+
if (state.mutation.status === "confirmed" || state.mutation.status === "conflict") await load();
|
|
23775
|
+
}
|
|
23776
|
+
return {
|
|
23777
|
+
snapshot,
|
|
23778
|
+
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
23779
|
+
load,
|
|
23780
|
+
approve: (row: ReviewRow) => submit("approve", row),
|
|
23781
|
+
upload: (row: ReviewRow, file: Blob) => submit("upload", row, file),
|
|
23782
|
+
async reconcile() {
|
|
23783
|
+
const operation = state.mutation.operation;
|
|
23784
|
+
if (closed || recovering || state.mutation.status !== "unknown" || !operation || !transport.lookup) return;
|
|
23785
|
+
recovering = true;
|
|
23786
|
+
try {
|
|
23787
|
+
const result = await transport.lookup(structuredClone(operation));
|
|
23788
|
+
// Denial or conflict during lookup does not prove the original write did not commit.
|
|
23789
|
+
finish(operation, result.status === "confirmed" ? result : { status: "unknown" });
|
|
23790
|
+
if (snapshot().mutation.status === "confirmed") await load();
|
|
23791
|
+
} catch {
|
|
23792
|
+
finish(operation, { status: "unknown" });
|
|
23793
|
+
} finally { recovering = false; }
|
|
23794
|
+
},
|
|
23795
|
+
// Dispose on logout/identity change; do not reuse rows or results across sessions.
|
|
23796
|
+
dispose() { closed = true; generation++; listeners.clear(); },
|
|
23797
|
+
};
|
|
23798
|
+
}
|
|
23799
|
+
|
|
23800
|
+
export class ReviewAccessDenied extends Error {}
|
|
23801
|
+
`;
|
|
23802
|
+
var STARTER_REVIEW_TRANSPORT = String.raw`import type { SingleAttemptFetch } from "@supacloud/js/contracts";
|
|
23803
|
+
import { ReviewAccessDenied, type ReviewOperation, type ReviewRow, type ReviewTransport, type ReviewWriteResult } from "./reviews";
|
|
23804
|
+
|
|
23805
|
+
/** Structural SDK boundary: application data is decoded, never propagated as SDK generics. */
|
|
23806
|
+
export interface ReviewSupabaseClient {
|
|
23807
|
+
from(table: "starter_reviews"): {
|
|
23808
|
+
select(columns: "id,state,version"): {
|
|
23809
|
+
order(column: "id"): { limit(count: number): PromiseLike<{ data: unknown; error: unknown; status: number }> };
|
|
23810
|
+
};
|
|
23811
|
+
};
|
|
23812
|
+
storage: {
|
|
23813
|
+
from(bucket: string): {
|
|
23814
|
+
upload(path: string, file: Blob, options: { upsert: boolean; contentType: string }): Promise<{ error: unknown }>;
|
|
23815
|
+
};
|
|
23816
|
+
};
|
|
23817
|
+
}
|
|
23818
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23819
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23820
|
+
}
|
|
23821
|
+
function record(value: unknown): Record<string, unknown> {
|
|
23822
|
+
if (!isRecord(value)) throw new Error("Invalid response");
|
|
23823
|
+
return value;
|
|
23824
|
+
}
|
|
23825
|
+
function isList(value: unknown): value is unknown[] { return Array.isArray(value); }
|
|
23826
|
+
function row(value: unknown): ReviewRow {
|
|
23827
|
+
const item = record(value);
|
|
23828
|
+
if (typeof item["id"] !== "string" || (item["state"] !== "draft" && item["state"] !== "approved")
|
|
23829
|
+
|| typeof item["version"] !== "number" || !Number.isSafeInteger(item["version"]) || item["version"] < 1) throw new Error("Invalid row");
|
|
23830
|
+
return { id: item["id"], state: item["state"], version: item["version"] };
|
|
23831
|
+
}
|
|
23832
|
+
function rejected(status: number, body: unknown): ReviewWriteResult {
|
|
23833
|
+
const code = record(body)["code"];
|
|
23834
|
+
if ((status === 401 || status === 403) && [
|
|
23835
|
+
"AUTHENTICATION_REQUIRED", "REVIEW_PERMISSION_DENIED", "COMMAND_REJECTED", "UPLOAD_DENIED",
|
|
23836
|
+
].includes(String(code))) return { status: "denied" };
|
|
23837
|
+
if (status === 409 && ["REVIEW_CONFLICT", "FEATURE_TRANSITION_CONFLICT", "COMMAND_IDEMPOTENCY_CONFLICT"]
|
|
23838
|
+
.includes(String(code))) return { status: "conflict" };
|
|
23839
|
+
return { status: "unknown" };
|
|
23840
|
+
}
|
|
23841
|
+
|
|
23842
|
+
/** Use the same user-scoped Supabase client and createAuthenticatedFetch session supplier. */
|
|
23843
|
+
export function createReviewTransport(options: {
|
|
23844
|
+
supabase: ReviewSupabaseClient;
|
|
23845
|
+
authenticatedFetch: SingleAttemptFetch;
|
|
23846
|
+
apiOrigin: string;
|
|
23847
|
+
lookup?: ReviewTransport["lookup"];
|
|
23848
|
+
}): ReviewTransport {
|
|
23849
|
+
const origin = new URL(options.apiOrigin);
|
|
23850
|
+
if (origin.protocol !== "https:" || origin.username || origin.password || origin.search || origin.hash
|
|
23851
|
+
|| origin.pathname !== "/") throw new Error("A fixed trusted HTTPS origin is required");
|
|
23852
|
+
async function request(path: string, body: unknown, operationId: string): Promise<{ response: Response; body: unknown }> {
|
|
23853
|
+
const response = await options.authenticatedFetch(new URL(path, origin), {
|
|
23854
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(15000),
|
|
23855
|
+
headers: { "content-type": "application/json", "idempotency-key": operationId, "x-request-id": operationId },
|
|
23856
|
+
body: JSON.stringify(body),
|
|
23857
|
+
});
|
|
23858
|
+
const result: unknown = await response.json();
|
|
23859
|
+
return { response, body: result };
|
|
23860
|
+
}
|
|
23861
|
+
return {
|
|
23862
|
+
lookup: options.lookup ?? (async operation => {
|
|
23863
|
+
const url = new URL("/reviews/" + encodeURIComponent(operation.reviewId)
|
|
23864
|
+
+ "/operations/" + encodeURIComponent(operation.operationId), origin);
|
|
23865
|
+
url.searchParams.set("kind", operation.kind);
|
|
23866
|
+
url.searchParams.set("expectedVersion", String(operation.expectedVersion));
|
|
23867
|
+
const response = await options.authenticatedFetch(url, {
|
|
23868
|
+
method: "GET", redirect: "error", cache: "no-store", signal: AbortSignal.timeout(15000),
|
|
23869
|
+
headers: { "x-request-id": operation.operationId },
|
|
23870
|
+
});
|
|
23871
|
+
if (!response.ok) return { status: "unknown" };
|
|
23872
|
+
const body: unknown = await response.json();
|
|
23873
|
+
const receipt = record(body);
|
|
23874
|
+
if (receipt["status"] !== "confirmed" || receipt["operationId"] !== operation.operationId
|
|
23875
|
+
|| receipt["reviewId"] !== operation.reviewId) return { status: "unknown" };
|
|
23876
|
+
return { status: "confirmed", operationId: operation.operationId, reviewId: operation.reviewId };
|
|
23877
|
+
}),
|
|
23878
|
+
async list() {
|
|
23879
|
+
const { data, error, status } = await options.supabase.from("starter_reviews")
|
|
23880
|
+
.select("id,state,version").order("id").limit(100);
|
|
23881
|
+
if (error) {
|
|
23882
|
+
if (status === 401 || status === 403) throw new ReviewAccessDenied();
|
|
23883
|
+
throw new Error("Review read failed");
|
|
23884
|
+
}
|
|
23885
|
+
if (!isList(data)) throw new Error("Invalid list");
|
|
23886
|
+
return data.map(row);
|
|
23887
|
+
},
|
|
23888
|
+
async approve(operation: ReviewOperation) {
|
|
23889
|
+
const result = await request("/reviews/" + encodeURIComponent(operation.reviewId) + "/approve",
|
|
23890
|
+
{ expectedVersion: operation.expectedVersion }, operation.operationId);
|
|
23891
|
+
if (!result.response.ok) return rejected(result.response.status, result.body);
|
|
23892
|
+
const value = record(result.body);
|
|
23893
|
+
if (value["state"] !== "approved" || value["version"] !== operation.expectedVersion + 1) return { status: "unknown" };
|
|
23894
|
+
return { status: "confirmed", operationId: operation.operationId, reviewId: operation.reviewId };
|
|
23895
|
+
},
|
|
23896
|
+
async upload(operation: ReviewOperation, file: Blob) {
|
|
23897
|
+
const input = { artifactId: operation.operationId, expectedVersion: operation.expectedVersion };
|
|
23898
|
+
const base = "/reviews/" + encodeURIComponent(operation.reviewId);
|
|
23899
|
+
const preview = await request(base + "/attachment-upload", input, operation.operationId);
|
|
23900
|
+
if (!preview.response.ok) {
|
|
23901
|
+
// These explicit preview rejections happen before any object or registration write.
|
|
23902
|
+
const code = record(preview.body)["code"];
|
|
23903
|
+
if ((preview.response.status === 501 && code === "UPLOADS_UNAVAILABLE")
|
|
23904
|
+
|| (preview.response.status === 400 && code === "UPLOAD_INPUT_INVALID")) return { status: "denied" };
|
|
23905
|
+
return rejected(preview.response.status, preview.body);
|
|
23906
|
+
}
|
|
23907
|
+
const location = record(preview.body);
|
|
23908
|
+
if (location["artifactId"] !== operation.operationId || location["bucketId"] !== "review-attachments"
|
|
23909
|
+
|| typeof location["objectPath"] !== "string"
|
|
23910
|
+
|| !location["objectPath"].endsWith("/" + operation.reviewId + "/" + operation.operationId + ".txt")) {
|
|
23911
|
+
return { status: "unknown" };
|
|
23912
|
+
}
|
|
23913
|
+
// Never overwrite an object or repeat this upload after a network failure.
|
|
23914
|
+
const stored = await options.supabase.storage.from("review-attachments")
|
|
23915
|
+
.upload(location["objectPath"], file, { upsert: false, contentType: "text/plain" });
|
|
23916
|
+
if (stored.error) return { status: "unknown" };
|
|
23917
|
+
const registered = await request(base + "/attachment-registration", input, operation.operationId);
|
|
23918
|
+
// The object is already stored. A rejection cannot roll back that side effect.
|
|
23919
|
+
if (!registered.response.ok) return { status: "unknown" };
|
|
23920
|
+
const value = record(registered.body);
|
|
23921
|
+
const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer())),
|
|
23922
|
+
byte => byte.toString(16).padStart(2, "0")).join("");
|
|
23923
|
+
if (value["artifactId"] !== operation.operationId || value["objectPath"] !== location["objectPath"]
|
|
23924
|
+
|| typeof value["runId"] !== "string" || value["sha256"] !== sha256
|
|
23925
|
+
|| value["bytes"] !== file.size) return { status: "unknown" };
|
|
23926
|
+
return { status: "confirmed", operationId: operation.operationId, reviewId: operation.reviewId };
|
|
23927
|
+
},
|
|
23928
|
+
};
|
|
23929
|
+
}
|
|
23930
|
+
`;
|
|
23931
|
+
var STARTER_REVIEW_CLIENT_TEST = String.raw`import { expect, test } from "bun:test";
|
|
23932
|
+
import { createReviewClient, createReviewPendingStore, ReviewAccessDenied, type ReviewOperation, type ReviewTransport, type ReviewRow, type ReviewWriteResult } from "../src/client/reviews";
|
|
23933
|
+
import { createReviewTransport } from "../src/client/review-transport";
|
|
23934
|
+
|
|
23935
|
+
const draft: ReviewRow = { id: "review-one", state: "draft", version: 1 };
|
|
23936
|
+
const uploadDraft: ReviewRow = { ...draft, id: "11111111-1111-4111-8111-111111111111" };
|
|
23937
|
+
function transport(overrides: Partial<ReviewTransport> = {}): ReviewTransport {
|
|
23938
|
+
return {
|
|
23939
|
+
list: async () => [draft],
|
|
23940
|
+
approve: async op => ({ status: "confirmed", operationId: op.operationId, reviewId: op.reviewId }),
|
|
23941
|
+
upload: async op => ({ status: "confirmed", operationId: op.operationId, reviewId: op.reviewId }),
|
|
23942
|
+
...overrides,
|
|
23943
|
+
};
|
|
23944
|
+
}
|
|
23945
|
+
test("list has loading, empty, permission and failure states; old results cannot replace new reads", async () => {
|
|
23946
|
+
let resolve: (rows: ReviewRow[]) => void = () => {};
|
|
23947
|
+
let calls = 0;
|
|
23948
|
+
const client = createReviewClient(transport({ list: () => ++calls === 1
|
|
23949
|
+
? new Promise(done => { resolve = done; }) : Promise.resolve([]) }));
|
|
23950
|
+
const loading = client.load();
|
|
23951
|
+
expect(client.snapshot().list.status).toBe("loading");
|
|
23952
|
+
await client.load();
|
|
23953
|
+
resolve([draft]); await loading;
|
|
23954
|
+
expect(client.snapshot().list.status).toBe("empty");
|
|
23955
|
+
for (const [error, status] of [[new ReviewAccessDenied(), "denied"], [new Error("private"), "error"]] as const) {
|
|
23956
|
+
const failed = createReviewClient(transport({ list: async () => { throw error; } }));
|
|
23957
|
+
await failed.load();
|
|
23958
|
+
expect(failed.snapshot().list).toEqual({ status, rows: [] });
|
|
23959
|
+
expect(JSON.stringify(failed.snapshot())).not.toContain("private");
|
|
23960
|
+
}
|
|
23961
|
+
});
|
|
23962
|
+
test("double submit sends one write; unknown retains its identity and only a lookup can confirm it", async () => {
|
|
23963
|
+
let sends = 0, lookups = 0, resolve: (result: ReviewWriteResult) => void = () => {};
|
|
23964
|
+
const client = createReviewClient(transport({
|
|
23965
|
+
approve: () => { sends++; return new Promise(done => { resolve = done; }); },
|
|
23966
|
+
lookup: async op => { lookups++; return { status: "confirmed", operationId: op.operationId, reviewId: op.reviewId }; },
|
|
23967
|
+
}));
|
|
23968
|
+
const pending = client.approve(draft);
|
|
23969
|
+
const operation = client.snapshot().mutation.operation;
|
|
23970
|
+
await client.approve(draft);
|
|
23971
|
+
resolve({ status: "unknown" }); await pending;
|
|
23972
|
+
await client.approve(draft);
|
|
23973
|
+
expect(sends).toBe(1);
|
|
23974
|
+
expect(client.snapshot().mutation).toEqual({ status: "unknown", operation });
|
|
23975
|
+
await client.reconcile();
|
|
23976
|
+
expect(sends).toBe(1); expect(lookups).toBe(1);
|
|
23977
|
+
expect(client.snapshot().mutation.status).toBe("confirmed");
|
|
23978
|
+
});
|
|
23979
|
+
test("conflict refreshes but does not retry the write; wrong receipt remains unknown", async () => {
|
|
23980
|
+
let writes = 0, reads = 0;
|
|
23981
|
+
const client = createReviewClient(transport({
|
|
23982
|
+
list: async () => { reads++; return []; },
|
|
23983
|
+
approve: async () => { writes++; return { status: "conflict" }; },
|
|
23984
|
+
}));
|
|
23985
|
+
await client.approve(draft);
|
|
23986
|
+
expect(writes).toBe(1); expect(reads).toBe(1);
|
|
23987
|
+
expect(client.snapshot().mutation.status).toBe("conflict");
|
|
23988
|
+
const wrong = createReviewClient(transport({
|
|
23989
|
+
approve: async () => ({ status: "confirmed", operationId: "foreign", reviewId: draft.id }),
|
|
23990
|
+
}));
|
|
23991
|
+
await wrong.approve(draft);
|
|
23992
|
+
expect(wrong.snapshot().mutation.status).toBe("unknown");
|
|
23993
|
+
});
|
|
23994
|
+
test("upload validates size/type, retains failure identity, and disposal drops late results", async () => {
|
|
23995
|
+
let uploads = 0;
|
|
23996
|
+
const client = createReviewClient(transport({ upload: async () => { uploads++; throw new Error("private"); } }));
|
|
23997
|
+
await client.upload(uploadDraft, new Blob(["bad"], { type: "application/pdf" }));
|
|
23998
|
+
expect(uploads).toBe(0);
|
|
23999
|
+
await client.upload(uploadDraft, new Blob(["ok"], { type: "text/plain" }));
|
|
24000
|
+
expect(uploads).toBe(1);
|
|
24001
|
+
expect(client.snapshot().mutation.status).toBe("unknown");
|
|
24002
|
+
await client.reconcile();
|
|
24003
|
+
expect(uploads).toBe(1);
|
|
24004
|
+
client.dispose();
|
|
24005
|
+
await client.upload(uploadDraft, new Blob(["ok"], { type: "text/plain" }));
|
|
24006
|
+
expect(uploads).toBe(1);
|
|
24007
|
+
});
|
|
24008
|
+
|
|
24009
|
+
test("upload-only input constraints never persist or dispatch an impossible request or block approval", async () => {
|
|
24010
|
+
let saves = 0, uploads = 0, approvals = 0, saved: ReviewOperation | null = null;
|
|
24011
|
+
const client = createReviewClient(transport({
|
|
24012
|
+
upload: async () => { uploads++; return { status: "unknown" }; },
|
|
24013
|
+
approve: async op => { approvals++; return { status: "confirmed", operationId: op.operationId, reviewId: op.reviewId }; },
|
|
24014
|
+
}), {
|
|
24015
|
+
load: () => saved, save: op => { saves++; saved = op; }, clear: () => { saved = null; },
|
|
24016
|
+
});
|
|
24017
|
+
await client.load();
|
|
24018
|
+
expect(client.snapshot().list.rows).toEqual([draft]);
|
|
24019
|
+
const file = new Blob(["hello"], { type: "text/plain" });
|
|
24020
|
+
await client.upload(draft, file);
|
|
24021
|
+
await client.upload({ ...uploadDraft, version: 2147483648 }, file);
|
|
24022
|
+
expect(uploads).toBe(0); expect(saves).toBe(0); expect(saved).toBeNull();
|
|
24023
|
+
expect(client.snapshot().mutation).toEqual({ status: "idle", operation: null });
|
|
24024
|
+
await client.approve(draft);
|
|
24025
|
+
expect(approvals).toBe(1); expect(saves).toBe(1);
|
|
24026
|
+
expect(client.snapshot().mutation.status).toBe("confirmed");
|
|
24027
|
+
});
|
|
24028
|
+
|
|
24029
|
+
test("explicit upload preview rejection clears pending before side effects, without retrying", async () => {
|
|
24030
|
+
for (const [status, code] of [[501, "UPLOADS_UNAVAILABLE"], [400, "UPLOAD_INPUT_INVALID"]] as const) {
|
|
24031
|
+
let requests = 0, uploads = 0, saved: ReviewOperation | null = null;
|
|
24032
|
+
const adapter = createReviewTransport({
|
|
24033
|
+
supabase: {
|
|
24034
|
+
storage: { from: () => ({ upload: async () => { uploads++; return { error: null }; } }) },
|
|
24035
|
+
} as unknown as Parameters<typeof createReviewTransport>[0]["supabase"],
|
|
24036
|
+
apiOrigin: "https://reviews.example.test",
|
|
24037
|
+
authenticatedFetch: async url => {
|
|
24038
|
+
requests++;
|
|
24039
|
+
return String(url).endsWith("/attachment-upload")
|
|
24040
|
+
? Response.json({ code }, { status }) : Response.json({ state: "approved", version: 2 });
|
|
24041
|
+
},
|
|
24042
|
+
});
|
|
24043
|
+
const client = createReviewClient({ ...adapter, list: async () => [uploadDraft] }, {
|
|
24044
|
+
load: () => saved, save: op => { saved = op; }, clear: () => { saved = null; },
|
|
24045
|
+
});
|
|
24046
|
+
await client.upload(uploadDraft, new Blob(["hello"], { type: "text/plain" }));
|
|
24047
|
+
expect(client.snapshot().mutation.status).toBe("denied");
|
|
24048
|
+
expect(saved).toBeNull(); expect(uploads).toBe(0); expect(requests).toBe(1);
|
|
24049
|
+
await client.reconcile();
|
|
24050
|
+
expect(requests).toBe(1);
|
|
24051
|
+
await client.approve(uploadDraft);
|
|
24052
|
+
expect(client.snapshot().mutation.status).toBe("confirmed");
|
|
24053
|
+
expect(requests).toBe(2);
|
|
24054
|
+
}
|
|
24055
|
+
});
|
|
24056
|
+
|
|
24057
|
+
test("HTTP approval makes one authenticated request and preserves denied, conflict and unknown results", async () => {
|
|
24058
|
+
const operation = { kind: "approve" as const, reviewId: draft.id, expectedVersion: 1, operationId: crypto.randomUUID() };
|
|
24059
|
+
for (const [httpStatus, body, expected] of [
|
|
24060
|
+
[200, { state: "approved", version: 2 }, "confirmed"],
|
|
24061
|
+
[403, { code: "REVIEW_PERMISSION_DENIED" }, "denied"],
|
|
24062
|
+
[409, { code: "COMMAND_IDEMPOTENCY_CONFLICT" }, "conflict"],
|
|
24063
|
+
[500, { code: "INTERNAL_ERROR" }, "unknown"],
|
|
24064
|
+
[200, { state: "approved", version: 99 }, "unknown"],
|
|
24065
|
+
] as const) {
|
|
24066
|
+
let calls = 0;
|
|
24067
|
+
const adapter = createReviewTransport({
|
|
24068
|
+
supabase: {} as Parameters<typeof createReviewTransport>[0]["supabase"],
|
|
24069
|
+
apiOrigin: "https://reviews.example.test",
|
|
24070
|
+
authenticatedFetch: async (url, init) => {
|
|
24071
|
+
calls++;
|
|
24072
|
+
expect(String(url)).toBe("https://reviews.example.test/reviews/review-one/approve");
|
|
24073
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBe(operation.operationId);
|
|
24074
|
+
expect(init?.redirect).toBe("error");
|
|
24075
|
+
expect(JSON.parse(String(init?.body))).toEqual({ expectedVersion: 1 });
|
|
24076
|
+
return Response.json(body, { status: httpStatus });
|
|
24077
|
+
},
|
|
24078
|
+
});
|
|
24079
|
+
expect((await adapter.approve(operation)).status).toBe(expected);
|
|
24080
|
+
expect(calls).toBe(1);
|
|
24081
|
+
}
|
|
24082
|
+
});
|
|
24083
|
+
|
|
24084
|
+
test("private upload verifies its binding and checksum; a lost registration response is never replayed", async () => {
|
|
24085
|
+
const operationId = crypto.randomUUID();
|
|
24086
|
+
const file = new Blob(["hello"], { type: "text/plain" });
|
|
24087
|
+
const path = "owner/" + uploadDraft.id + "/" + operationId + ".txt";
|
|
24088
|
+
let uploads = 0, requests = 0;
|
|
24089
|
+
const supabase = {
|
|
24090
|
+
storage: { from(bucket: string) {
|
|
24091
|
+
expect(bucket).toBe("review-attachments");
|
|
24092
|
+
return { async upload(objectPath: string, body: Blob, options: { upsert: boolean }) {
|
|
24093
|
+
uploads++;
|
|
24094
|
+
expect(objectPath).toBe(path); expect(body).toBe(file); expect(options.upsert).toBe(false);
|
|
24095
|
+
return { error: null };
|
|
24096
|
+
} };
|
|
24097
|
+
} },
|
|
24098
|
+
} as unknown as Parameters<typeof createReviewTransport>[0]["supabase"];
|
|
24099
|
+
const adapter = createReviewTransport({
|
|
24100
|
+
supabase, apiOrigin: "https://reviews.example.test",
|
|
24101
|
+
authenticatedFetch: async url => {
|
|
24102
|
+
requests++;
|
|
24103
|
+
if (String(url).endsWith("/attachment-upload")) {
|
|
24104
|
+
return Response.json({ artifactId: operationId, bucketId: "review-attachments", objectPath: path });
|
|
24105
|
+
}
|
|
24106
|
+
throw new Error("Registration response lost");
|
|
24107
|
+
},
|
|
24108
|
+
});
|
|
24109
|
+
const operation = { kind: "upload" as const, reviewId: uploadDraft.id, expectedVersion: 1, operationId };
|
|
24110
|
+
await expect(adapter.upload(operation, file)).rejects.toThrow("Registration response lost");
|
|
24111
|
+
expect(uploads).toBe(1); expect(requests).toBe(2);
|
|
24112
|
+
const confirmed = createReviewTransport({
|
|
24113
|
+
supabase, apiOrigin: "https://reviews.example.test",
|
|
24114
|
+
authenticatedFetch: async url => String(url).endsWith("/attachment-upload")
|
|
24115
|
+
? Response.json({ artifactId: operationId, bucketId: "review-attachments", objectPath: path })
|
|
24116
|
+
: Response.json({ artifactId: operationId, objectPath: path, runId: "run-one", bytes: 5,
|
|
24117
|
+
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }),
|
|
24118
|
+
});
|
|
24119
|
+
expect((await confirmed.upload(operation, file)).status).toBe("confirmed");
|
|
24120
|
+
});
|
|
24121
|
+
|
|
24122
|
+
test("reload restores pending identity, denies replay and resolves only a matching durable receipt", async () => {
|
|
24123
|
+
const values = new Map<string, string>();
|
|
24124
|
+
const storage = {
|
|
24125
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
24126
|
+
setItem: (key: string, value: string) => { values.set(key, value); },
|
|
24127
|
+
removeItem: (key: string) => { values.delete(key); },
|
|
24128
|
+
};
|
|
24129
|
+
const scope = { projectId: "p", tenantId: "t", applicationId: "reviews", subject: "owner" };
|
|
24130
|
+
const pending = createReviewPendingStore(storage, scope);
|
|
24131
|
+
let writes = 0;
|
|
24132
|
+
const first = createReviewClient(transport({ approve: async () => { writes++; return { status: "unknown" }; } }), pending);
|
|
24133
|
+
await first.approve(draft);
|
|
24134
|
+
const operation = first.snapshot().mutation.operation!;
|
|
24135
|
+
first.dispose();
|
|
24136
|
+
const reloaded = createReviewClient(transport({
|
|
24137
|
+
approve: async () => { writes++; return { status: "unknown" }; },
|
|
24138
|
+
lookup: async op => ({ status: "confirmed", operationId: op.operationId, reviewId: op.reviewId }),
|
|
24139
|
+
}), createReviewPendingStore(storage, scope));
|
|
24140
|
+
expect(reloaded.snapshot().mutation).toEqual({ status: "unknown", operation });
|
|
24141
|
+
await reloaded.approve(draft);
|
|
24142
|
+
expect(writes).toBe(1);
|
|
24143
|
+
expect(createReviewPendingStore(storage, { ...scope, subject: "other" }).load()).toBeNull();
|
|
24144
|
+
expect(createReviewPendingStore(storage, { ...scope, projectId: "other" }).load()).toBeNull();
|
|
24145
|
+
await reloaded.reconcile();
|
|
24146
|
+
expect(reloaded.snapshot().mutation.status).toBe("confirmed");
|
|
24147
|
+
expect(pending.load()).toBeNull();
|
|
24148
|
+
});
|
|
24149
|
+
|
|
24150
|
+
test("denied or conflicting recovery never unlocks an unknown operation", async () => {
|
|
24151
|
+
for (const status of ["denied", "conflict"] as const) {
|
|
24152
|
+
let writes = 0;
|
|
24153
|
+
const client = createReviewClient(transport({
|
|
24154
|
+
approve: async () => { writes++; return { status: "unknown" }; },
|
|
24155
|
+
lookup: async () => ({ status }),
|
|
24156
|
+
}));
|
|
24157
|
+
await client.approve(draft);
|
|
24158
|
+
const original = client.snapshot().mutation.operation;
|
|
24159
|
+
await client.reconcile(); await client.approve(draft);
|
|
24160
|
+
expect(writes).toBe(1);
|
|
24161
|
+
expect(client.snapshot().mutation).toEqual({ status: "unknown", operation: original });
|
|
24162
|
+
}
|
|
24163
|
+
});
|
|
24164
|
+
|
|
24165
|
+
test("unavailable recovery storage prevents dispatch, and disposal leaves pending evidence", async () => {
|
|
24166
|
+
let writes = 0;
|
|
24167
|
+
const client = createReviewClient(transport({ approve: async () => { writes++; return { status: "unknown" }; } }), {
|
|
24168
|
+
load: () => null, save: () => { throw new Error("Storage unavailable"); }, clear: () => {},
|
|
24169
|
+
});
|
|
24170
|
+
await client.approve(draft);
|
|
24171
|
+
expect(writes).toBe(0);
|
|
24172
|
+
expect(client.snapshot().mutation.status).toBe("unknown");
|
|
24173
|
+
let saved: ReviewOperation | null = null, cleared = 0, resolve: (result: ReviewWriteResult) => void = () => {};
|
|
24174
|
+
const closed = createReviewClient(transport({ approve: () => new Promise(done => { resolve = done; }) }), {
|
|
24175
|
+
load: () => saved, save: op => { saved = op; }, clear: () => { cleared++; },
|
|
24176
|
+
});
|
|
24177
|
+
const request = closed.approve(draft);
|
|
24178
|
+
const op = closed.snapshot().mutation.operation!;
|
|
24179
|
+
closed.dispose();
|
|
24180
|
+
resolve({ status: "confirmed", operationId: op.operationId, reviewId: op.reviewId }); await request;
|
|
24181
|
+
expect<ReviewOperation | null>(saved).toEqual(op); expect(cleared).toBe(0);
|
|
24182
|
+
});
|
|
24183
|
+
|
|
24184
|
+
test("default recovery transport performs one read without body or mutation retry", async () => {
|
|
24185
|
+
let calls = 0;
|
|
24186
|
+
const operation = { kind: "approve" as const, reviewId: draft.id, expectedVersion: 1, operationId: crypto.randomUUID() };
|
|
24187
|
+
const adapter = createReviewTransport({
|
|
24188
|
+
supabase: {} as Parameters<typeof createReviewTransport>[0]["supabase"],
|
|
24189
|
+
apiOrigin: "https://reviews.example.test",
|
|
24190
|
+
authenticatedFetch: async (input, init) => {
|
|
24191
|
+
calls++;
|
|
24192
|
+
const url = new URL(String(input));
|
|
24193
|
+
expect(url.pathname).toBe("/reviews/" + draft.id + "/operations/" + operation.operationId);
|
|
24194
|
+
expect(url.searchParams.get("expectedVersion")).toBe("1");
|
|
24195
|
+
expect(init?.method).toBe("GET"); expect(init?.body).toBeUndefined();
|
|
24196
|
+
expect(init?.cache).toBe("no-store");
|
|
24197
|
+
return Response.json({ status: "confirmed", operationId: operation.operationId, reviewId: operation.reviewId });
|
|
24198
|
+
},
|
|
24199
|
+
});
|
|
24200
|
+
expect((await adapter.lookup!(operation)).status).toBe("confirmed");
|
|
24201
|
+
expect(calls).toBe(1);
|
|
24202
|
+
});
|
|
24203
|
+
|
|
24204
|
+
test("corrupt pending records fail closed and cannot be overwritten by another operation", async () => {
|
|
24205
|
+
let value: string | null = "{broken", writes = 0;
|
|
24206
|
+
const pending = createReviewPendingStore({
|
|
24207
|
+
getItem: () => value, setItem: (_key, next) => { value = next; }, removeItem: () => { value = null; },
|
|
24208
|
+
}, { projectId: "p", tenantId: "t", applicationId: "a", subject: "s" });
|
|
24209
|
+
const blocked = createReviewClient(transport({ approve: async () => { writes++; return { status: "unknown" }; } }), pending);
|
|
24210
|
+
await blocked.approve(draft);
|
|
24211
|
+
expect(writes).toBe(0);
|
|
24212
|
+
expect(blocked.snapshot().mutation).toEqual({ status: "unknown", operation: null });
|
|
24213
|
+
expect(value).toBe("{broken");
|
|
24214
|
+
value = null;
|
|
24215
|
+
const original = { kind: "approve" as const, reviewId: draft.id, expectedVersion: 1, operationId: crypto.randomUUID() };
|
|
24216
|
+
pending.save(original);
|
|
24217
|
+
const other = { ...original, operationId: crypto.randomUUID() };
|
|
24218
|
+
expect(() => pending.save(other)).toThrow("Another unresolved operation");
|
|
24219
|
+
expect(() => pending.clear(other)).toThrow("identity changed");
|
|
24220
|
+
expect(pending.load()).toEqual(original);
|
|
24221
|
+
});
|
|
24222
|
+
|
|
24223
|
+
test("a competing pending operation is recovered instead of the undispatched attempt", async () => {
|
|
24224
|
+
let value: string | null = null, writes = 0, lookedUp: unknown;
|
|
24225
|
+
const pending = createReviewPendingStore({
|
|
24226
|
+
getItem: () => value, setItem: (_key, next) => { value = next; }, removeItem: () => { value = null; },
|
|
24227
|
+
}, { projectId: "p", tenantId: "t", applicationId: "a", subject: "s" });
|
|
24228
|
+
const client = createReviewClient(transport({
|
|
24229
|
+
approve: async () => { writes++; return { status: "unknown" }; },
|
|
24230
|
+
lookup: async op => {
|
|
24231
|
+
lookedUp = op;
|
|
24232
|
+
return { status: "confirmed", operationId: op.operationId, reviewId: op.reviewId };
|
|
24233
|
+
},
|
|
24234
|
+
}), pending);
|
|
24235
|
+
const original = { kind: "upload" as const, reviewId: uploadDraft.id, expectedVersion: 1, operationId: crypto.randomUUID() };
|
|
24236
|
+
pending.save(original);
|
|
24237
|
+
await client.approve(draft);
|
|
24238
|
+
expect(writes).toBe(0);
|
|
24239
|
+
expect(client.snapshot().mutation).toEqual({ status: "unknown", operation: original });
|
|
24240
|
+
await client.reconcile();
|
|
24241
|
+
expect(lookedUp).toEqual(original);
|
|
24242
|
+
expect(pending.load()).toBeNull();
|
|
24243
|
+
});
|
|
24244
|
+
|
|
24245
|
+
test("disposal during submitting notification prevents dispatch and preserves pending identity", async () => {
|
|
24246
|
+
let writes = 0, saved: ReviewOperation | null = null;
|
|
24247
|
+
const client = createReviewClient(transport({
|
|
24248
|
+
approve: async () => { writes++; return { status: "unknown" }; },
|
|
24249
|
+
}), { load: () => saved, save: op => { saved = op; }, clear: () => { saved = null; } });
|
|
24250
|
+
client.subscribe(() => { if (client.snapshot().mutation.status === "submitting") client.dispose(); });
|
|
24251
|
+
await client.approve(draft);
|
|
24252
|
+
expect(writes).toBe(0);
|
|
24253
|
+
expect(client.snapshot().mutation.operation).toEqual(saved);
|
|
24254
|
+
expect(saved).not.toBeNull();
|
|
24255
|
+
});
|
|
24256
|
+
|
|
24257
|
+
test("registration rejection after object upload retains pending identity and never submits a replacement", async () => {
|
|
24258
|
+
for (const [status, code] of [[403, "UPLOAD_DENIED"], [409, "COMMAND_IDEMPOTENCY_CONFLICT"]] as const) {
|
|
24259
|
+
let uploads = 0, requests = 0, saved: ReviewOperation | null = null;
|
|
24260
|
+
const adapter = createReviewTransport({
|
|
24261
|
+
supabase: {
|
|
24262
|
+
storage: { from: () => ({ upload: async () => { uploads++; return { error: null }; } }) },
|
|
24263
|
+
} as unknown as Parameters<typeof createReviewTransport>[0]["supabase"],
|
|
24264
|
+
apiOrigin: "https://reviews.example.test",
|
|
24265
|
+
authenticatedFetch: async (input, init) => {
|
|
24266
|
+
requests++;
|
|
24267
|
+
if (String(input).endsWith("/attachment-upload")) {
|
|
24268
|
+
const operationId = new Headers(init?.headers).get("idempotency-key")!;
|
|
24269
|
+
return Response.json({ artifactId: operationId, bucketId: "review-attachments",
|
|
24270
|
+
objectPath: "owner/" + uploadDraft.id + "/" + operationId + ".txt" });
|
|
24271
|
+
}
|
|
24272
|
+
return Response.json({ code }, { status });
|
|
24273
|
+
},
|
|
24274
|
+
});
|
|
24275
|
+
const client = createReviewClient(adapter, {
|
|
24276
|
+
load: () => saved, save: op => { saved = op; }, clear: () => { saved = null; },
|
|
24277
|
+
});
|
|
24278
|
+
const file = new Blob(["hello"], { type: "text/plain" });
|
|
24279
|
+
await client.upload(uploadDraft, file);
|
|
24280
|
+
expect(client.snapshot().mutation.status).toBe("unknown");
|
|
24281
|
+
expect(client.snapshot().mutation.operation).toEqual(saved);
|
|
24282
|
+
expect(saved).not.toBeNull();
|
|
24283
|
+
await client.upload(uploadDraft, file);
|
|
24284
|
+
expect(uploads).toBe(1);
|
|
24285
|
+
expect(requests).toBe(2);
|
|
24286
|
+
}
|
|
24287
|
+
});
|
|
24288
|
+
|
|
24289
|
+
|
|
24290
|
+
for (const kind of ["approve", "upload"] as const) {
|
|
24291
|
+
const submit = (client: ReturnType<typeof createReviewClient>) => kind === "approve"
|
|
24292
|
+
? client.approve(draft) : client.upload(uploadDraft, new Blob(["hello"], { type: "text/plain" }));
|
|
24293
|
+
for (const failure of ["silent", "read-error", "operation", "version", "kind"] as const) {
|
|
24294
|
+
test(kind + " verifies pending readback before any transport: " + failure, async () => {
|
|
24295
|
+
let saved: ReviewOperation | null = null, attempted = false, sends = 0;
|
|
24296
|
+
const client = createReviewClient(transport({
|
|
24297
|
+
approve: async () => { sends++; return { status: "unknown" }; },
|
|
24298
|
+
upload: async () => { sends++; return { status: "unknown" }; },
|
|
24299
|
+
}), {
|
|
24300
|
+
load() {
|
|
24301
|
+
if (attempted && failure === "read-error") throw new Error("private storage failure");
|
|
24302
|
+
return saved;
|
|
24303
|
+
},
|
|
24304
|
+
save(operation) {
|
|
24305
|
+
attempted = true;
|
|
24306
|
+
if (failure === "silent") return;
|
|
24307
|
+
saved = { ...operation };
|
|
24308
|
+
if (failure === "operation") saved.operationId = crypto.randomUUID();
|
|
24309
|
+
if (failure === "version") saved.expectedVersion++;
|
|
24310
|
+
if (failure === "kind") saved.kind = kind === "approve" ? "upload" : "approve";
|
|
24311
|
+
},
|
|
24312
|
+
clear() { throw new Error("Must not clear an unconfirmed operation"); },
|
|
24313
|
+
});
|
|
24314
|
+
await submit(client);
|
|
24315
|
+
await submit(client);
|
|
24316
|
+
expect(sends).toBe(0);
|
|
24317
|
+
expect(client.snapshot().mutation.status).toBe("unknown");
|
|
24318
|
+
expect(JSON.stringify(client.snapshot())).not.toContain("private storage failure");
|
|
24319
|
+
expect(client.snapshot().mutation.operation).toEqual(failure === "read-error" ? null : saved);
|
|
24320
|
+
});
|
|
24321
|
+
}
|
|
24322
|
+
for (const resultStatus of ["confirmed", "denied", "conflict"] as const) {
|
|
24323
|
+
test(kind + " retains pending after silent cleanup: " + resultStatus, async () => {
|
|
24324
|
+
let saved: ReviewOperation | null = null, sends = 0, clears = 0;
|
|
24325
|
+
const send = async (operation: ReviewOperation): Promise<ReviewWriteResult> => {
|
|
24326
|
+
sends++;
|
|
24327
|
+
return resultStatus === "confirmed"
|
|
24328
|
+
? { status: "confirmed", operationId: operation.operationId, reviewId: operation.reviewId }
|
|
24329
|
+
: { status: resultStatus };
|
|
24330
|
+
};
|
|
24331
|
+
const client = createReviewClient(transport({ approve: send, upload: send }), {
|
|
24332
|
+
load: () => saved, save: operation => { saved = operation; }, clear: () => { clears++; },
|
|
24333
|
+
});
|
|
24334
|
+
await submit(client);
|
|
24335
|
+
expect(client.snapshot().mutation).toEqual({ status: "unknown", operation: saved });
|
|
24336
|
+
await submit(client);
|
|
24337
|
+
expect(sends).toBe(1);
|
|
24338
|
+
expect(clears).toBe(1);
|
|
24339
|
+
expect(saved).not.toBeNull();
|
|
24340
|
+
});
|
|
24341
|
+
}
|
|
24342
|
+
test(kind + " accepts exact JSON readback and recovers once without another write", async () => {
|
|
24343
|
+
const values = new Map<string, string>();
|
|
24344
|
+
const pending = createReviewPendingStore({
|
|
24345
|
+
getItem: key => values.get(key) ?? null,
|
|
24346
|
+
setItem: (key, value) => { values.set(key, value); },
|
|
24347
|
+
removeItem: key => { values.delete(key); },
|
|
24348
|
+
}, { projectId: "project", tenantId: "tenant", applicationId: "reviews", subject: "owner" });
|
|
24349
|
+
let sends = 0, reads = 0;
|
|
24350
|
+
const send = async (operation: ReviewOperation): Promise<ReviewWriteResult> => {
|
|
24351
|
+
sends++;
|
|
24352
|
+
// Stored field ordering differs from the newly created operation.
|
|
24353
|
+
expect(pending.load()).toEqual(operation);
|
|
24354
|
+
return { status: "unknown" };
|
|
24355
|
+
};
|
|
24356
|
+
const client = createReviewClient(transport({
|
|
24357
|
+
approve: send, upload: send,
|
|
24358
|
+
lookup: async operation => {
|
|
24359
|
+
reads++;
|
|
24360
|
+
return { status: "confirmed", operationId: operation.operationId, reviewId: operation.reviewId };
|
|
24361
|
+
},
|
|
24362
|
+
}), pending);
|
|
24363
|
+
await submit(client);
|
|
24364
|
+
await submit(client);
|
|
24365
|
+
expect(sends).toBe(1);
|
|
24366
|
+
await client.reconcile();
|
|
24367
|
+
expect(client.snapshot().mutation.status).toBe("confirmed");
|
|
24368
|
+
expect(pending.load()).toBeNull();
|
|
24369
|
+
expect(sends).toBe(1);
|
|
24370
|
+
expect(reads).toBe(1);
|
|
24371
|
+
});
|
|
24372
|
+
}
|
|
24373
|
+
`;
|
|
24374
|
+
|
|
24375
|
+
// src/shared/tools/app-starter-receipts.ts
|
|
24376
|
+
var STARTER_REVIEW_RECEIPTS = `import { Effect } from "effect";
|
|
24377
|
+
import { Controller, DB_CLIENT, Get, Inject, Injectable, Param, Query, REQUEST_CONTEXT } from "@supacloud/app";
|
|
24378
|
+
import { ApplicationError, createSchemaDecoder } from "@supacloud/elysia";
|
|
24379
|
+
import { ReceiptOperation, ReceiptParams, ReceiptQuery, ReceiptResult,
|
|
24380
|
+
type ReviewOperation, type ReviewReceipt } from "./contracts";
|
|
24381
|
+
export type { ReviewOperation, ReviewReceipt } from "./contracts";
|
|
24382
|
+
|
|
24383
|
+
const decodeOperation = createSchemaDecoder(ReceiptOperation, { normalize: false });
|
|
24384
|
+
export interface ReviewReceiptPort {
|
|
24385
|
+
lookupOperation(context: unknown, operation: ReviewOperation): Promise<ReviewReceipt>;
|
|
24386
|
+
}
|
|
24387
|
+
type ReceiptError =
|
|
24388
|
+
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
24389
|
+
| { readonly _tag: "REVIEW_PERMISSION_DENIED" }
|
|
24390
|
+
| { readonly _tag: "UPLOAD_DENIED" }
|
|
24391
|
+
| { readonly _tag: "COMMAND_REJECTED" }
|
|
24392
|
+
| { readonly _tag: "COMMAND_IDEMPOTENCY_CONFLICT" }
|
|
24393
|
+
| { readonly _tag: "RECEIPT_INPUT_INVALID" }
|
|
24394
|
+
| { readonly _tag: "RECEIPTS_UNAVAILABLE" };
|
|
24395
|
+
|
|
24396
|
+
@Injectable({ scope: "request" })
|
|
24397
|
+
export class ReviewReceipts {
|
|
24398
|
+
constructor(@Inject(DB_CLIENT) private readonly store: Partial<ReviewReceiptPort>,
|
|
24399
|
+
@Inject(REQUEST_CONTEXT) private readonly context: unknown) {}
|
|
24400
|
+
lookup(operation: ReviewOperation): Promise<ReviewReceipt> {
|
|
24401
|
+
let input: ReviewOperation;
|
|
24402
|
+
try { input = decodeOperation(operation); }
|
|
24403
|
+
catch {
|
|
24404
|
+
throw new ApplicationError("Invalid receipt input", { status: 400, code: "RECEIPT_INPUT_INVALID" });
|
|
24405
|
+
}
|
|
24406
|
+
if (!this.store.lookupOperation) {
|
|
24407
|
+
throw new ApplicationError("Receipt lookup is unavailable", { status: 503, code: "RECEIPTS_UNAVAILABLE" });
|
|
24408
|
+
}
|
|
24409
|
+
return this.store.lookupOperation(this.context, input);
|
|
24410
|
+
}
|
|
24411
|
+
}
|
|
24412
|
+
|
|
24413
|
+
@Controller("/reviews")
|
|
24414
|
+
export class ReviewReceiptsController {
|
|
24415
|
+
constructor(@Inject(ReviewReceipts) private readonly receipts: ReviewReceipts) {}
|
|
24416
|
+
@Get("/:id/operations/:operationId", {
|
|
24417
|
+
params: ReceiptParams, query: ReceiptQuery, responses: { 200: ReceiptResult },
|
|
24418
|
+
effect: {
|
|
24419
|
+
required: true, dependencies: [], retry: "none",
|
|
24420
|
+
errors: [
|
|
24421
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
24422
|
+
{ tag: "REVIEW_PERMISSION_DENIED", status: 403, code: "REVIEW_PERMISSION_DENIED" },
|
|
24423
|
+
{ tag: "UPLOAD_DENIED", status: 403, code: "UPLOAD_DENIED" },
|
|
24424
|
+
{ tag: "COMMAND_REJECTED", status: 403, code: "COMMAND_REJECTED" },
|
|
24425
|
+
{ tag: "COMMAND_IDEMPOTENCY_CONFLICT", status: 409, code: "COMMAND_IDEMPOTENCY_CONFLICT" },
|
|
24426
|
+
{ tag: "RECEIPT_INPUT_INVALID", status: 400, code: "RECEIPT_INPUT_INVALID" },
|
|
24427
|
+
{ tag: "RECEIPTS_UNAVAILABLE", status: 503, code: "RECEIPTS_UNAVAILABLE" },
|
|
24428
|
+
],
|
|
24429
|
+
},
|
|
24430
|
+
})
|
|
24431
|
+
lookup(@Param("id") reviewId: string, @Param("operationId") operationId: string,
|
|
24432
|
+
@Query("kind") kind: ReviewOperation["kind"], @Query("expectedVersion") expectedVersion: string): Effect.Effect<ReviewReceipt, ReceiptError, never> {
|
|
24433
|
+
return Effect.promise(() => this.receipts.lookup({ reviewId, operationId, kind, expectedVersion: Number(expectedVersion) }));
|
|
24434
|
+
}
|
|
24435
|
+
}
|
|
24436
|
+
`;
|
|
24437
|
+
|
|
23432
24438
|
// src/shared/tools/app-starter-query.ts
|
|
23433
24439
|
var STARTER_REVIEW_QUERY = `import type { SupabaseClient } from "@supabase/supabase-js";
|
|
23434
24440
|
import type { ReviewRow } from "../../db/schema";
|
|
@@ -23481,6 +24487,330 @@ export async function listReviews(
|
|
|
23481
24487
|
}
|
|
23482
24488
|
`;
|
|
23483
24489
|
|
|
24490
|
+
// src/shared/tools/app-starter-tasks.ts
|
|
24491
|
+
var STARTER_TASK_CONTRACTS = `import { t, type UnwrapSchema } from "elysia";
|
|
24492
|
+
import { AttachmentResult } from "./attachment";
|
|
24493
|
+
import { REVIEW_VERSION_MAX } from "./limits";
|
|
24494
|
+
import { ReviewVersion } from "./contracts";
|
|
24495
|
+
|
|
24496
|
+
export const TaskParams = t.Object({ id: t.String({ minLength: 1, maxLength: 100 }) }, { additionalProperties: false });
|
|
24497
|
+
export const RetryBody = t.Object({
|
|
24498
|
+
expectedVersion: t.Integer({ minimum: 2, maximum: REVIEW_VERSION_MAX }),
|
|
24499
|
+
expectedRunId: t.String({ format: "uuid" }),
|
|
24500
|
+
recoveryId: t.String({ format: "uuid" }),
|
|
24501
|
+
}, { additionalProperties: false });
|
|
24502
|
+
export const RetryInput = t.Object({
|
|
24503
|
+
reviewId: TaskParams.properties.id, ...RetryBody.properties,
|
|
24504
|
+
}, { additionalProperties: false });
|
|
24505
|
+
export const TaskStatus = t.Object({
|
|
24506
|
+
reviewId: TaskParams.properties.id,
|
|
24507
|
+
version: ReviewVersion,
|
|
24508
|
+
artifactId: t.Nullable(t.String({ format: "uuid" })),
|
|
24509
|
+
runId: t.Nullable(t.String({ format: "uuid" })),
|
|
24510
|
+
state: t.Union([t.Literal("not_submitted"), t.Literal("queued"), t.Literal("running"),
|
|
24511
|
+
t.Literal("completed"), t.Literal("failed"), t.Literal("cancelled")]),
|
|
24512
|
+
attempts: t.Integer({ minimum: 0 }),
|
|
24513
|
+
maxAttempts: t.Literal(3),
|
|
24514
|
+
canRetry: t.Boolean(),
|
|
24515
|
+
result: t.Nullable(AttachmentResult),
|
|
24516
|
+
}, { additionalProperties: false });
|
|
24517
|
+
export const RetryResult = t.Object({
|
|
24518
|
+
reviewId: TaskParams.properties.id,
|
|
24519
|
+
previousRunId: t.String({ format: "uuid" }),
|
|
24520
|
+
runId: t.String({ format: "uuid" }),
|
|
24521
|
+
state: t.Literal("queued"),
|
|
24522
|
+
}, { additionalProperties: false });
|
|
24523
|
+
export type ReviewTaskStatus = UnwrapSchema<typeof TaskStatus>;
|
|
24524
|
+
export type ReviewRetryBody = UnwrapSchema<typeof RetryBody>;
|
|
24525
|
+
export type ReviewRetryInput = UnwrapSchema<typeof RetryInput>;
|
|
24526
|
+
export type ReviewRetryResult = UnwrapSchema<typeof RetryResult>;
|
|
24527
|
+
`;
|
|
24528
|
+
var STARTER_TASK_FEATURE = `import { Effect } from "effect";
|
|
24529
|
+
import { Body, Controller, DB_CLIENT, Get, Inject, Injectable, Param, Post, REQUEST_CONTEXT } from "@supacloud/app";
|
|
24530
|
+
import { ApplicationError } from "@supacloud/elysia";
|
|
24531
|
+
import { RetryBody, RetryResult, TaskParams, TaskStatus,
|
|
24532
|
+
type ReviewRetryBody, type ReviewRetryInput, type ReviewRetryResult, type ReviewTaskStatus } from "./task-contracts";
|
|
24533
|
+
|
|
24534
|
+
export interface ReviewTaskPort {
|
|
24535
|
+
attachmentTask(context: unknown, reviewId: string): Promise<ReviewTaskStatus>;
|
|
24536
|
+
retryAttachmentTask(context: unknown, input: ReviewRetryInput): Promise<ReviewRetryResult>;
|
|
24537
|
+
}
|
|
24538
|
+
type TaskError =
|
|
24539
|
+
| { readonly _tag: "AUTHENTICATION_REQUIRED" }
|
|
24540
|
+
| { readonly _tag: "TASK_INPUT_INVALID" }
|
|
24541
|
+
| { readonly _tag: "TASK_DENIED" }
|
|
24542
|
+
| { readonly _tag: "TASK_CONFLICT" }
|
|
24543
|
+
| { readonly _tag: "TASK_UNAVAILABLE" }
|
|
24544
|
+
| { readonly _tag: "TASK_OUTCOME_UNKNOWN" };
|
|
24545
|
+
|
|
24546
|
+
@Injectable({ scope: "request" })
|
|
24547
|
+
export class ReviewTasks {
|
|
24548
|
+
constructor(@Inject(DB_CLIENT) private readonly store: Partial<ReviewTaskPort>,
|
|
24549
|
+
@Inject(REQUEST_CONTEXT) private readonly context: unknown) {}
|
|
24550
|
+
|
|
24551
|
+
status(id: string): Promise<ReviewTaskStatus> {
|
|
24552
|
+
if (!this.store.attachmentTask) throw new ApplicationError("Attachment tasks are not configured", { status: 501, code: "TASK_UNAVAILABLE" });
|
|
24553
|
+
return this.store.attachmentTask(this.context, id);
|
|
24554
|
+
}
|
|
24555
|
+
retry(input: ReviewRetryInput): Promise<ReviewRetryResult> {
|
|
24556
|
+
if (!this.store.retryAttachmentTask) throw new ApplicationError("Attachment tasks are not configured", { status: 501, code: "TASK_UNAVAILABLE" });
|
|
24557
|
+
return this.store.retryAttachmentTask(this.context, input);
|
|
24558
|
+
}
|
|
24559
|
+
}
|
|
24560
|
+
|
|
24561
|
+
@Controller("/reviews")
|
|
24562
|
+
export class ReviewTasksController {
|
|
24563
|
+
constructor(@Inject(ReviewTasks) private readonly tasks: ReviewTasks) {}
|
|
24564
|
+
|
|
24565
|
+
@Get("/:id/attachment-task", {
|
|
24566
|
+
params: TaskParams, responses: { 200: TaskStatus },
|
|
24567
|
+
effect: { required: true, dependencies: [], retry: "none", errors: [
|
|
24568
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
24569
|
+
{ tag: "TASK_INPUT_INVALID", status: 400, code: "TASK_INPUT_INVALID" },
|
|
24570
|
+
{ tag: "TASK_DENIED", status: 403, code: "TASK_DENIED" },
|
|
24571
|
+
{ tag: "TASK_CONFLICT", status: 409, code: "TASK_CONFLICT" },
|
|
24572
|
+
{ tag: "TASK_UNAVAILABLE", status: 501, code: "TASK_UNAVAILABLE" },
|
|
24573
|
+
{ tag: "TASK_OUTCOME_UNKNOWN", status: 503, code: "TASK_OUTCOME_UNKNOWN" },
|
|
24574
|
+
] },
|
|
24575
|
+
})
|
|
24576
|
+
status(@Param("id") id: string): Effect.Effect<ReviewTaskStatus, TaskError, never> {
|
|
24577
|
+
return Effect.promise(() => this.tasks.status(id));
|
|
24578
|
+
}
|
|
24579
|
+
|
|
24580
|
+
@Post("/:id/attachment-task/retry", {
|
|
24581
|
+
params: TaskParams, body: RetryBody, responses: { 200: RetryResult },
|
|
24582
|
+
effect: { required: true, dependencies: [], retry: "none", errors: [
|
|
24583
|
+
{ tag: "AUTHENTICATION_REQUIRED", status: 401, code: "AUTHENTICATION_REQUIRED" },
|
|
24584
|
+
{ tag: "TASK_INPUT_INVALID", status: 400, code: "TASK_INPUT_INVALID" },
|
|
24585
|
+
{ tag: "TASK_DENIED", status: 403, code: "TASK_DENIED" },
|
|
24586
|
+
{ tag: "TASK_CONFLICT", status: 409, code: "TASK_CONFLICT" },
|
|
24587
|
+
{ tag: "TASK_UNAVAILABLE", status: 501, code: "TASK_UNAVAILABLE" },
|
|
24588
|
+
{ tag: "TASK_OUTCOME_UNKNOWN", status: 503, code: "TASK_OUTCOME_UNKNOWN" },
|
|
24589
|
+
] },
|
|
24590
|
+
})
|
|
24591
|
+
retry(@Param("id") id: string, @Body() body: ReviewRetryBody): Effect.Effect<ReviewRetryResult, TaskError, never> {
|
|
24592
|
+
return Effect.promise(() => this.tasks.retry({ ...body, reviewId: id }));
|
|
24593
|
+
}
|
|
24594
|
+
}
|
|
24595
|
+
`;
|
|
24596
|
+
var STARTER_TASK_SCHEMA = `-- Forward-only operator provisioning after 004 and the platform Workflow runtime.
|
|
24597
|
+
CREATE TABLE public.starter_attachment_retries (
|
|
24598
|
+
run_id uuid PRIMARY KEY,
|
|
24599
|
+
review_id text NOT NULL REFERENCES public.starter_attachments(review_id),
|
|
24600
|
+
previous_run_id uuid NOT NULL UNIQUE,
|
|
24601
|
+
generation integer NOT NULL CHECK (generation > 0),
|
|
24602
|
+
UNIQUE(review_id,generation),
|
|
24603
|
+
CHECK (run_id <> previous_run_id)
|
|
24604
|
+
);
|
|
24605
|
+
ALTER TABLE public.starter_attachment_retries ENABLE ROW LEVEL SECURITY;
|
|
24606
|
+
REVOKE ALL ON public.starter_attachment_retries FROM PUBLIC, anon, authenticated;
|
|
24607
|
+
GRANT SELECT,INSERT ON public.starter_attachment_retries TO starter_review_http;
|
|
24608
|
+
GRANT SELECT ON public.starter_attachment_retries TO starter_review_worker;
|
|
24609
|
+
DROP POLICY IF EXISTS starter_http_retry_read ON public.starter_attachment_retries;
|
|
24610
|
+
CREATE POLICY starter_http_retry_read ON public.starter_attachment_retries
|
|
24611
|
+
FOR SELECT TO starter_review_http USING (true);
|
|
24612
|
+
DROP POLICY IF EXISTS starter_http_retry_insert ON public.starter_attachment_retries;
|
|
24613
|
+
CREATE POLICY starter_http_retry_insert ON public.starter_attachment_retries
|
|
24614
|
+
FOR INSERT TO starter_review_http WITH CHECK (true);
|
|
24615
|
+
DROP POLICY IF EXISTS starter_worker_retry_read ON public.starter_attachment_retries;
|
|
24616
|
+
CREATE POLICY starter_worker_retry_read ON public.starter_attachment_retries
|
|
24617
|
+
FOR SELECT TO starter_review_worker USING (true);
|
|
24618
|
+
-- Trusted HTTP adapters authorize the review before inspecting its bound run.
|
|
24619
|
+
GRANT EXECUTE ON FUNCTION supacloud_workflows.snapshot(uuid,boolean) TO starter_review_http;
|
|
24620
|
+
`;
|
|
24621
|
+
var STARTER_TASK_HOST = `import { t } from "elysia";
|
|
24622
|
+
import { createTransactionalCommand, plaintextCommandInput } from "@supacloud/commands";
|
|
24623
|
+
import { CommandError } from "@supacloud/contracts";
|
|
24624
|
+
import { createPostgresCommandStore, type CommandDatabase, type CommandTransaction } from "@supacloud/db";
|
|
24625
|
+
import { ApplicationError, createSchemaDecoder, requireTrustedIdentity } from "@supacloud/elysia";
|
|
24626
|
+
import { AttachmentInput, AttachmentResult } from "../review/attachment";
|
|
24627
|
+
import { ReviewResult } from "../review/contracts";
|
|
24628
|
+
import { RetryInput, RetryResult, TaskParams, TaskStatus, type ReviewRetryInput } from "../review/task-contracts";
|
|
24629
|
+
import type { ReviewTaskPort } from "../review/tasks";
|
|
24630
|
+
import type { attachments, attachmentRetries, ReviewRow } from "../../db/schema";
|
|
24631
|
+
|
|
24632
|
+
export interface ReviewTaskOptions { database: CommandDatabase; projectId: string; tenantId: string }
|
|
24633
|
+
type Binding = Pick<typeof attachments.$inferSelect, "artifactId" | "runId">
|
|
24634
|
+
& Pick<typeof attachmentRetries.$inferSelect, "generation" | "runId">;
|
|
24635
|
+
const decodeInput = createSchemaDecoder(RetryInput, { normalize: false });
|
|
24636
|
+
const decodeRetry = createSchemaDecoder(RetryResult, { normalize: false });
|
|
24637
|
+
const decodeStatus = createSchemaDecoder(TaskStatus, { normalize: false });
|
|
24638
|
+
const decodeParams = createSchemaDecoder(TaskParams, { normalize: false });
|
|
24639
|
+
const decodeReview: (value: unknown) => Pick<ReviewRow, "state" | "version"> =
|
|
24640
|
+
createSchemaDecoder(ReviewResult, { normalize: false });
|
|
24641
|
+
const decodeResult = createSchemaDecoder(AttachmentResult, { normalize: false });
|
|
24642
|
+
const decodeBinding: (value: unknown) => Binding = createSchemaDecoder(t.Object({
|
|
24643
|
+
artifactId: t.String({ format: "uuid" }), runId: t.String({ format: "uuid" }),
|
|
24644
|
+
generation: t.Integer({ minimum: 0 }),
|
|
24645
|
+
}), { normalize: false });
|
|
24646
|
+
const decodeRun = createSchemaDecoder(t.Object({
|
|
24647
|
+
runId: t.String({ format: "uuid" }), workflowName: t.Literal("review.verify-attachment"),
|
|
24648
|
+
workflowVersion: t.Literal("1"),
|
|
24649
|
+
status: t.Union([t.Literal("queued"), t.Literal("running"), t.Literal("completed"), t.Literal("failed"), t.Literal("cancelled")]),
|
|
24650
|
+
input: AttachmentInput, output: t.Unknown(), idempotent: t.Boolean(),
|
|
24651
|
+
steps: t.Array(t.Object({
|
|
24652
|
+
stepKey: t.Literal("verify"), attempts: t.Integer({ minimum: 0 }),
|
|
24653
|
+
maxAttempts: t.Literal(3),
|
|
24654
|
+
status: t.Union([t.Literal("queued"), t.Literal("running"), t.Literal("completed"), t.Literal("failed"), t.Literal("dead_lettered"), t.Literal("cancelled")]),
|
|
24655
|
+
}, { additionalProperties: true }), { minItems: 1, maxItems: 1 }),
|
|
24656
|
+
}, { additionalProperties: true }), { normalize: false });
|
|
24657
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24658
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24659
|
+
}
|
|
24660
|
+
function record(value: unknown): Record<string, unknown> {
|
|
24661
|
+
if (!isRecord(value)) throw new Error("Invalid task record");
|
|
24662
|
+
return value;
|
|
24663
|
+
}
|
|
24664
|
+
function rows(value: unknown): Record<string, unknown>[] {
|
|
24665
|
+
if (!Array.isArray(value)) throw new Error("Invalid task rows");
|
|
24666
|
+
return value.map(record);
|
|
24667
|
+
}
|
|
24668
|
+
function capture(value: unknown): ReviewRetryInput {
|
|
24669
|
+
try {
|
|
24670
|
+
const input = decodeInput(value);
|
|
24671
|
+
return { ...input, recoveryId: input.recoveryId.toLowerCase(), expectedRunId: input.expectedRunId.toLowerCase() };
|
|
24672
|
+
} catch { throw new ApplicationError("Invalid task request", { status: 400, code: "TASK_INPUT_INVALID" }); }
|
|
24673
|
+
}
|
|
24674
|
+
function failure(error: unknown): never {
|
|
24675
|
+
if (error instanceof ApplicationError) throw error;
|
|
24676
|
+
if (error instanceof CommandError && error.code === "COMMAND_REJECTED") {
|
|
24677
|
+
throw new ApplicationError("Task access denied", { status: 403, code: "TASK_DENIED" });
|
|
24678
|
+
}
|
|
24679
|
+
if (error instanceof CommandError && error.code === "COMMAND_IDEMPOTENCY_CONFLICT") {
|
|
24680
|
+
throw new ApplicationError("Task changed or is not retryable", { status: 409, code: "TASK_CONFLICT" });
|
|
24681
|
+
}
|
|
24682
|
+
throw new ApplicationError("Task outcome could not be confirmed", { status: 503, code: "TASK_OUTCOME_UNKNOWN" });
|
|
24683
|
+
}
|
|
24684
|
+
const conflict = () => { throw new CommandError("COMMAND_IDEMPOTENCY_CONFLICT"); };
|
|
24685
|
+
|
|
24686
|
+
export async function createReviewTaskAdapters(options: ReviewTaskOptions): Promise<ReviewTaskPort> {
|
|
24687
|
+
const { database, projectId, tenantId } = options;
|
|
24688
|
+
const configured = rows(await database.transaction(tx => tx.query(
|
|
24689
|
+
"SELECT project_id,tenant_id FROM public.starter_application WHERE singleton",
|
|
24690
|
+
)));
|
|
24691
|
+
if (!projectId.trim() || !tenantId.trim() || configured.length !== 1
|
|
24692
|
+
|| configured[0]?.["project_id"] !== projectId || configured[0]?.["tenant_id"] !== tenantId) {
|
|
24693
|
+
throw new Error("Task database project/tenant binding mismatch");
|
|
24694
|
+
}
|
|
24695
|
+
function actor(context: unknown): string {
|
|
24696
|
+
const who = requireTrustedIdentity(context);
|
|
24697
|
+
const access = record(record(context)["access"]);
|
|
24698
|
+
if (access["projectId"] !== projectId || access["tenantId"] !== tenantId
|
|
24699
|
+
|| !Array.isArray(access["permissions"]) || !access["permissions"].includes("review.approve")) {
|
|
24700
|
+
throw new ApplicationError("Task access denied", { status: 403, code: "TASK_DENIED" });
|
|
24701
|
+
}
|
|
24702
|
+
return who.subject;
|
|
24703
|
+
}
|
|
24704
|
+
async function authorize(tx: CommandTransaction, subject: string, reviewId: string) {
|
|
24705
|
+
const found = rows(await tx.query(
|
|
24706
|
+
"SELECT r.state,r.version FROM public.starter_reviews r " +
|
|
24707
|
+
"JOIN public.starter_members m ON m.subject=r.owner_id CROSS JOIN public.starter_application p " +
|
|
24708
|
+
"WHERE r.id=$1 AND r.owner_id=$2 AND m.enabled AND m.can_approve " +
|
|
24709
|
+
"AND p.singleton AND p.project_id=$3 AND p.tenant_id=$4 FOR UPDATE OF r,m FOR SHARE OF p",
|
|
24710
|
+
[reviewId, subject, projectId, tenantId],
|
|
24711
|
+
));
|
|
24712
|
+
if (found.length !== 1) throw new ApplicationError("Task access denied", { status: 403, code: "TASK_DENIED" });
|
|
24713
|
+
return decodeReview(found[0]);
|
|
24714
|
+
}
|
|
24715
|
+
async function binding(tx: CommandTransaction, reviewId: string) {
|
|
24716
|
+
const found = rows(await tx.query(
|
|
24717
|
+
'SELECT a.artifact_id AS "artifactId",COALESCE(retry.run_id,a.run_id) AS "runId",COALESCE(retry.generation,0) AS generation ' +
|
|
24718
|
+
"FROM public.starter_attachments a LEFT JOIN LATERAL (" +
|
|
24719
|
+
"SELECT run_id,generation FROM public.starter_attachment_retries WHERE review_id=a.review_id ORDER BY generation DESC LIMIT 1" +
|
|
24720
|
+
") retry ON true WHERE a.review_id=$1", [reviewId],
|
|
24721
|
+
));
|
|
24722
|
+
return found.length === 0 ? null : decodeBinding(found[0]);
|
|
24723
|
+
}
|
|
24724
|
+
async function snapshot(tx: CommandTransaction, runId: string) {
|
|
24725
|
+
return rows(await tx.query("SELECT supacloud_workflows.snapshot($1::uuid,false) AS run", [runId]))[0]?.["run"];
|
|
24726
|
+
}
|
|
24727
|
+
function boundRun(value: unknown, reviewId: string, version: number, bound: NonNullable<Awaited<ReturnType<typeof binding>>>) {
|
|
24728
|
+
const run = decodeRun(value);
|
|
24729
|
+
if (run.runId !== bound.runId || run.input.reviewId !== reviewId
|
|
24730
|
+
|| run.input.version !== version || run.input.artifactId !== bound.artifactId) {
|
|
24731
|
+
throw new Error("Task Workflow binding mismatch");
|
|
24732
|
+
}
|
|
24733
|
+
const step = run.steps[0];
|
|
24734
|
+
if (!step || (run.status === "queued" && step.status !== "queued")
|
|
24735
|
+
|| (run.status === "running" && step.status !== "queued" && step.status !== "running")
|
|
24736
|
+
|| (run.status === "completed" && step.status !== "completed")
|
|
24737
|
+
|| (run.status === "failed" && step.status !== "failed" && step.status !== "dead_lettered")
|
|
24738
|
+
|| (run.status === "cancelled" && step.status !== "cancelled")) {
|
|
24739
|
+
throw new Error("Task Workflow state mismatch");
|
|
24740
|
+
}
|
|
24741
|
+
return run;
|
|
24742
|
+
}
|
|
24743
|
+
const retry = createTransactionalCommand({
|
|
24744
|
+
name: "review.retry-attachment", store: createPostgresCommandStore(database),
|
|
24745
|
+
inputCodec: plaintextCommandInput, input: capture, result: decodeRetry,
|
|
24746
|
+
async authorize(who, input, tx) {
|
|
24747
|
+
try {
|
|
24748
|
+
const review = await authorize(tx, who.actorId, input.reviewId);
|
|
24749
|
+
return review.state === "approved" && review.version === input.expectedVersion ? "allow" : "deny";
|
|
24750
|
+
} catch (error) {
|
|
24751
|
+
if (error instanceof ApplicationError && error.code === "TASK_DENIED") return "deny";
|
|
24752
|
+
throw error;
|
|
24753
|
+
}
|
|
24754
|
+
},
|
|
24755
|
+
async execute(tx, input) {
|
|
24756
|
+
const bound = await binding(tx, input.reviewId);
|
|
24757
|
+
if (!bound || bound.runId !== input.expectedRunId || input.recoveryId === bound.runId) return conflict();
|
|
24758
|
+
const previous = boundRun(await snapshot(tx, bound.runId), input.reviewId, input.expectedVersion, bound);
|
|
24759
|
+
if (previous.status !== "failed") return conflict();
|
|
24760
|
+
if (await snapshot(tx, input.recoveryId) !== null) return conflict();
|
|
24761
|
+
const started = rows(await tx.query(
|
|
24762
|
+
"SELECT supacloud_workflows.start_run($1::uuid,'review.verify-attachment','1','verify',$2::text::jsonb,3) AS run",
|
|
24763
|
+
[input.recoveryId, JSON.stringify(previous.input)],
|
|
24764
|
+
))[0]?.["run"];
|
|
24765
|
+
const next = boundRun(started, input.reviewId, input.expectedVersion, { ...bound, runId: input.recoveryId });
|
|
24766
|
+
if (next.idempotent || next.status !== "queued") return conflict();
|
|
24767
|
+
await tx.query("INSERT INTO public.starter_attachment_retries(run_id,review_id,previous_run_id,generation) VALUES($1,$2,$3,$4)",
|
|
24768
|
+
[input.recoveryId, input.reviewId, bound.runId, bound.generation + 1]);
|
|
24769
|
+
return { reviewId: input.reviewId, previousRunId: bound.runId, runId: input.recoveryId, state: "queued" as const };
|
|
24770
|
+
},
|
|
24771
|
+
audit: { event: "review.attachment-retried", details: input => ({
|
|
24772
|
+
reviewId: input.reviewId, previousRunId: input.expectedRunId, runId: input.recoveryId,
|
|
24773
|
+
}) },
|
|
24774
|
+
});
|
|
24775
|
+
return {
|
|
24776
|
+
async attachmentTask(context, reviewId) {
|
|
24777
|
+
try {
|
|
24778
|
+
const subject = actor(context);
|
|
24779
|
+
try { decodeParams({ id: reviewId }); }
|
|
24780
|
+
catch { throw new ApplicationError("Invalid task request", { status: 400, code: "TASK_INPUT_INVALID" }); }
|
|
24781
|
+
return await database.transaction(async tx => {
|
|
24782
|
+
const review = await authorize(tx, subject, reviewId);
|
|
24783
|
+
const bound = await binding(tx, reviewId);
|
|
24784
|
+
const empty = { reviewId, version: review.version, artifactId: bound?.artifactId ?? null,
|
|
24785
|
+
runId: null, state: "not_submitted", attempts: 0, maxAttempts: 3, canRetry: false, result: null } as const;
|
|
24786
|
+
if (!bound) return decodeStatus(empty);
|
|
24787
|
+
const value = await snapshot(tx, bound.runId);
|
|
24788
|
+
if (value === null && review.state === "draft" && bound.generation === 0) return decodeStatus(empty);
|
|
24789
|
+
const run = boundRun(value, reviewId, review.version, bound);
|
|
24790
|
+
const step = run.steps[0];
|
|
24791
|
+
if (!step) throw new Error("Missing task step");
|
|
24792
|
+
const result = run.status === "completed" ? decodeResult(run.output) : null;
|
|
24793
|
+
if (result && (result.reviewId !== reviewId || result.version !== review.version || result.artifactId !== bound.artifactId)) {
|
|
24794
|
+
throw new Error("Task result binding mismatch");
|
|
24795
|
+
}
|
|
24796
|
+
return decodeStatus({ ...empty, runId: run.runId,
|
|
24797
|
+
state: run.status === "running" && step.status === "queued" ? "queued" : run.status,
|
|
24798
|
+
attempts: step.attempts, canRetry: run.status === "failed", result });
|
|
24799
|
+
});
|
|
24800
|
+
} catch (error) { return failure(error); }
|
|
24801
|
+
},
|
|
24802
|
+
async retryAttachmentTask(context, value) {
|
|
24803
|
+
try {
|
|
24804
|
+
const subject = actor(context), input = capture(value);
|
|
24805
|
+
const receipt = await retry.execute({ actorId: subject, tenantId }, input.recoveryId, input);
|
|
24806
|
+
if (receipt.status !== "confirmed") throw new CommandError("COMMAND_OUTCOME_UNKNOWN");
|
|
24807
|
+
return receipt.result;
|
|
24808
|
+
} catch (error) { return failure(error); }
|
|
24809
|
+
},
|
|
24810
|
+
};
|
|
24811
|
+
}
|
|
24812
|
+
`;
|
|
24813
|
+
|
|
23484
24814
|
// src/shared/tools/app-starter.ts
|
|
23485
24815
|
var { compiler: compilerMetadata2, app: appMetadata2, elysia: elysiaMetadata2, commands: commandsMetadata, contracts: contractsMetadata, db: dbMetadata, sdk: sdkMetadata } = starter_metadata_default.packages;
|
|
23486
24816
|
var json2 = (value) => `${JSON.stringify(value, null, 2)}
|
|
@@ -23489,6 +24819,10 @@ function appStarterFiles(name) {
|
|
|
23489
24819
|
return {
|
|
23490
24820
|
...starterDatabaseFiles(),
|
|
23491
24821
|
"src/review/list-reviews.ts": STARTER_REVIEW_QUERY,
|
|
24822
|
+
"src/review/task-contracts.ts": STARTER_TASK_CONTRACTS,
|
|
24823
|
+
"src/review/tasks.ts": STARTER_TASK_FEATURE,
|
|
24824
|
+
"src/host/review-tasks.ts": STARTER_TASK_HOST,
|
|
24825
|
+
"migrations/005-review-task-recovery.sql": STARTER_TASK_SCHEMA,
|
|
23492
24826
|
"package.json": json2({
|
|
23493
24827
|
name,
|
|
23494
24828
|
version: "0.0.0",
|
|
@@ -23609,6 +24943,7 @@ export default defineSupacloudConfig({
|
|
|
23609
24943
|
{ source: "migrations/002-review-attachments.sql", version: "2", name: "review_attachments", executor: "project-migration" },
|
|
23610
24944
|
{ source: "migrations/003-review-uploads.sql", version: "3", name: "review_uploads", executor: "operator-provisioning" },
|
|
23611
24945
|
{ source: "migrations/004-review-runtime-roles.sql", version: "4", name: "review_runtime_roles", executor: "operator-provisioning" },
|
|
24946
|
+
{ source: "migrations/005-review-task-recovery.sql", version: "5", name: "review_task_recovery", executor: "operator-provisioning" },
|
|
23612
24947
|
],
|
|
23613
24948
|
},
|
|
23614
24949
|
},
|
|
@@ -23780,8 +25115,11 @@ import {
|
|
|
23780
25115
|
} from "@supacloud/app";
|
|
23781
25116
|
import { ApplicationError, assertFeatureTransition, createSchemaDecoder } from "@supacloud/elysia";
|
|
23782
25117
|
import { ApproveBody, HealthResult, Params, ReviewResult, type ApprovalBody, type Review } from "./contracts";
|
|
25118
|
+
import { REVIEW_VERSION_MAX } from "./limits";
|
|
23783
25119
|
import { VerifyReviewAttachment } from "./attachment";
|
|
23784
25120
|
import { ReviewUploads, ReviewUploadsController, type ReviewUploadPort } from "./uploads";
|
|
25121
|
+
import { ReviewReceipts, ReviewReceiptsController, type ReviewReceiptPort } from "./receipts";
|
|
25122
|
+
import { ReviewTasks, ReviewTasksController, type ReviewTaskPort } from "./tasks";
|
|
23785
25123
|
|
|
23786
25124
|
export const reviewSpec = defineFeatureSpec({
|
|
23787
25125
|
name: "review",
|
|
@@ -23797,7 +25135,7 @@ export const reviewSpec = defineFeatureSpec({
|
|
|
23797
25135
|
});
|
|
23798
25136
|
|
|
23799
25137
|
// Production implementations must bind both operations to the current transaction.
|
|
23800
|
-
export interface ReviewStore extends Partial<ReviewUploadPort> {
|
|
25138
|
+
export interface ReviewStore extends Partial<ReviewUploadPort>, Partial<ReviewReceiptPort>, Partial<ReviewTaskPort> {
|
|
23801
25139
|
get(table: string, key: string): unknown;
|
|
23802
25140
|
set(table: string, key: string, value: Review): void | Promise<void>;
|
|
23803
25141
|
}
|
|
@@ -23828,6 +25166,9 @@ export class ApproveReview {
|
|
|
23828
25166
|
if (current.version !== expectedVersion) {
|
|
23829
25167
|
throw new ApplicationError("Review state or version changed", { status: 409, code: "REVIEW_CONFLICT" });
|
|
23830
25168
|
}
|
|
25169
|
+
if (current.version === REVIEW_VERSION_MAX) {
|
|
25170
|
+
throw new ApplicationError("Review version is exhausted", { status: 409, code: "REVIEW_CONFLICT" });
|
|
25171
|
+
}
|
|
23831
25172
|
const next = {
|
|
23832
25173
|
state: assertFeatureTransition(reviewSpec, current.state, "approve"),
|
|
23833
25174
|
version: current.version + 1,
|
|
@@ -23887,25 +25228,34 @@ export class ReviewController {
|
|
|
23887
25228
|
|
|
23888
25229
|
export const ReviewFeature = defineFeatureSlice({
|
|
23889
25230
|
name: "review", tags: ["type:feature"], spec: reviewSpec,
|
|
23890
|
-
providers: [ApproveReview, VerifyReviewAttachment, ReviewUploads
|
|
25231
|
+
providers: [ApproveReview, VerifyReviewAttachment, ReviewUploads, ReviewReceipts, ReviewTasks],
|
|
25232
|
+
controllers: [ReviewController, ReviewUploadsController, ReviewReceiptsController, ReviewTasksController],
|
|
23891
25233
|
jobs: [VerifyReviewAttachment],
|
|
23892
25234
|
});
|
|
23893
25235
|
`,
|
|
23894
25236
|
"src/review/attachment.ts": STARTER_REVIEW_JOB,
|
|
25237
|
+
"src/review/limits.ts": `// Matches the existing PostgreSQL integer version columns; widening requires a migration.
|
|
25238
|
+
export const REVIEW_VERSION_MAX = 2147483647;
|
|
25239
|
+
`,
|
|
23895
25240
|
"src/review/contracts.ts": `import { t, type UnwrapSchema } from "elysia";
|
|
25241
|
+
import { REVIEW_VERSION_MAX } from "./limits";
|
|
23896
25242
|
export const Params = t.Object({ id: t.String({ minLength: 1 }) });
|
|
23897
|
-
export const
|
|
25243
|
+
export const ReviewVersion = t.Integer({ minimum: 1, maximum: REVIEW_VERSION_MAX });
|
|
25244
|
+
export const ApproveBody = t.Object({ expectedVersion: ReviewVersion });
|
|
23898
25245
|
export const ApprovalInput = t.Object({ ...Params.properties, ...ApproveBody.properties }, { additionalProperties: false });
|
|
23899
25246
|
export type ApprovalBody = UnwrapSchema<typeof ApproveBody>;
|
|
23900
25247
|
export const ReviewResult = t.Object({
|
|
23901
25248
|
state: t.Union([t.Literal("draft"), t.Literal("approved")]),
|
|
23902
|
-
version:
|
|
25249
|
+
version: ReviewVersion,
|
|
23903
25250
|
});
|
|
23904
25251
|
export type Review = UnwrapSchema<typeof ReviewResult>;
|
|
23905
25252
|
export const HealthResult = t.Object({ ok: t.Boolean() });
|
|
23906
25253
|
export const UploadParams = t.Object({ id: t.String({ format: "uuid" }) });
|
|
23907
25254
|
export const UploadBody = t.Object({
|
|
23908
|
-
artifactId: t.String({ format: "uuid" }), expectedVersion:
|
|
25255
|
+
artifactId: t.String({ format: "uuid" }), expectedVersion: ReviewVersion,
|
|
25256
|
+
}, { additionalProperties: false });
|
|
25257
|
+
export const UploadInput = t.Object({
|
|
25258
|
+
reviewId: UploadParams.properties.id, ...UploadBody.properties,
|
|
23909
25259
|
}, { additionalProperties: false });
|
|
23910
25260
|
export const UploadLocation = t.Object({
|
|
23911
25261
|
artifactId: t.String({ format: "uuid" }), bucketId: t.String(), objectPath: t.String(),
|
|
@@ -23914,12 +25264,41 @@ export const UploadResult = t.Object({
|
|
|
23914
25264
|
artifactId: t.String({ format: "uuid" }), runId: t.String({ format: "uuid" }),
|
|
23915
25265
|
objectPath: t.String(), sha256: t.String({ pattern: "^[a-f0-9]{64}$" }), bytes: t.Integer({ minimum: 1, maximum: 1048576 }),
|
|
23916
25266
|
});
|
|
25267
|
+
export const ReceiptParams = t.Object({
|
|
25268
|
+
id: t.String({ minLength: 1 }), operationId: t.String({ format: "uuid" }),
|
|
25269
|
+
});
|
|
25270
|
+
export const ReceiptQuery = t.Object({
|
|
25271
|
+
kind: t.Union([t.Literal("approve"), t.Literal("upload")]),
|
|
25272
|
+
expectedVersion: t.String({ pattern: "^[1-9][0-9]*$", maxLength: 16 }),
|
|
25273
|
+
}, { additionalProperties: false });
|
|
25274
|
+
export const ReceiptResult = t.Object({
|
|
25275
|
+
status: t.Union([t.Literal("confirmed"), t.Literal("unknown")]),
|
|
25276
|
+
operationId: t.String({ format: "uuid" }), reviewId: t.String({ minLength: 1 }),
|
|
25277
|
+
});
|
|
25278
|
+
export const ReceiptOperation = t.Object({
|
|
25279
|
+
kind: ReceiptQuery.properties.kind,
|
|
25280
|
+
reviewId: ReceiptParams.properties.id,
|
|
25281
|
+
operationId: ReceiptParams.properties.operationId,
|
|
25282
|
+
expectedVersion: ApprovalInput.properties.expectedVersion,
|
|
25283
|
+
}, { additionalProperties: false });
|
|
25284
|
+
export type ReviewOperation = UnwrapSchema<typeof ReceiptOperation>;
|
|
25285
|
+
export type ReviewReceipt = UnwrapSchema<typeof ReceiptResult>;
|
|
25286
|
+
export type ReviewUploadBody = UnwrapSchema<typeof UploadBody>;
|
|
25287
|
+
export type ReviewUploadInput = UnwrapSchema<typeof UploadInput>;
|
|
25288
|
+
export type ReviewUploadLocation = UnwrapSchema<typeof UploadLocation>;
|
|
25289
|
+
export type ReviewUploadResult = UnwrapSchema<typeof UploadResult>;
|
|
23917
25290
|
`,
|
|
25291
|
+
"src/review/receipts.ts": STARTER_REVIEW_RECEIPTS,
|
|
23918
25292
|
"src/review/uploads.ts": STARTER_UPLOAD_FEATURE,
|
|
25293
|
+
"src/client/reviews.ts": STARTER_REVIEW_CLIENT,
|
|
25294
|
+
"src/client/review-transport.ts": STARTER_REVIEW_TRANSPORT,
|
|
25295
|
+
"tests/review-client.test.ts": STARTER_REVIEW_CLIENT_TEST,
|
|
23919
25296
|
"src/host/review-uploads.ts": STARTER_UPLOAD_ADAPTER,
|
|
23920
25297
|
"src/host/review-postgres.ts": STARTER_REVIEW_POSTGRES,
|
|
23921
25298
|
"src/host/review-attachments.ts": STARTER_ATTACHMENT_POSTGRES,
|
|
23922
25299
|
"src/host/review-attachment-worker.ts": STARTER_ATTACHMENT_WORKER,
|
|
25300
|
+
"src/host/review-worker-observability.ts": STARTER_WORKER_OBSERVABILITY,
|
|
25301
|
+
"src/host/review-execution-observability.ts": STARTER_EXECUTION_OBSERVABILITY,
|
|
23923
25302
|
"src/delivery-worker.ts": STARTER_ATTACHMENT_DELIVERY_WORKER,
|
|
23924
25303
|
"src/delivery-host.ts": STARTER_REVIEW_DELIVERY_HOST,
|
|
23925
25304
|
"migrations/001-review.sql": STARTER_REVIEW_SCHEMA,
|
|
@@ -24017,6 +25396,25 @@ It is not a complete Maker-Checker workflow or a production authorization policy
|
|
|
24017
25396
|
|
|
24018
25397
|
## Frontend SDK Boundary
|
|
24019
25398
|
|
|
25399
|
+
src/client/reviews.ts supplies framework-neutral loading/empty/denied/error and
|
|
25400
|
+
submission/conflict/unknown states. src/client/review-transport.ts binds ordinary
|
|
25401
|
+
reads and private uploads to the existing user-scoped Supabase client, and
|
|
25402
|
+
approval/registration to createAuthenticatedFetch. It never uses a service key.
|
|
25403
|
+
Bind snapshot/subscribe to the view or SVAdmin store; dispose on identity change.
|
|
25404
|
+
The example lists at most 100 rows. Loading and permission failures clear rows.
|
|
25405
|
+
Unknown writes keep their operation ID and block resubmission. The default
|
|
25406
|
+
lookup calls GET /reviews/:id/operations/:operationId with kind and
|
|
25407
|
+
expectedVersion. It rechecks current authorization and reads the original
|
|
25408
|
+
durable receipt; current row state alone cannot confirm the operation.
|
|
25409
|
+
Construct createReviewPendingStore(localStorage, { projectId, tenantId,
|
|
25410
|
+
applicationId, subject }) and pass it as createReviewClient's second argument.
|
|
25411
|
+
The scope must come from the current authenticated session and application
|
|
25412
|
+
binding. It stores no token or file bytes. Reload restores unknown, then
|
|
25413
|
+
reconcile() performs only a receipt read; denied/conflicting lookup stays
|
|
25414
|
+
unknown. Storage failures block writes. Dispose never deletes pending evidence.
|
|
25415
|
+
Use one active mutation owner per identity/application: localStorage is not a
|
|
25416
|
+
cross-tab atomic lock. Do not clear unresolved records to enable another write.
|
|
25417
|
+
|
|
24020
25418
|
Use @supacloud/js as the platform SDK around your existing user-scoped
|
|
24021
25419
|
@supabase/supabase-js client. Pass that same client to existing UI providers;
|
|
24022
25420
|
supacloud.supabase exposes it unchanged. Keep UI routing, forms and query
|
|
@@ -24126,11 +25524,12 @@ This is not production-role acceptance. Ordinary API clients cannot edit members
|
|
|
24126
25524
|
or the database binding. No migration, binding, member or review is auto-created
|
|
24127
25525
|
by the HTTP host. This SQL file is not a migration runner or a recovery receipt.
|
|
24128
25526
|
|
|
24129
|
-
The starter's delivery.build.migrations explicitly declares these
|
|
25527
|
+
The starter's delivery.build.migrations explicitly declares these five files.
|
|
24130
25528
|
Immutable targets archive their exact bytes and a migrations.json inventory;
|
|
24131
25529
|
project migrations and operator provisioning use separate directories.
|
|
24132
25530
|
001-002 declare project migration handling. 003 changes Storage policies and
|
|
24133
|
-
004 creates cluster-wide roles
|
|
25531
|
+
004 creates cluster-wide roles and 005 adds the task recovery ledger and scoped
|
|
25532
|
+
backend grants, so these declare operator provisioning; ordinary
|
|
24134
25533
|
project-role permission to perform those changes has not been established.
|
|
24135
25534
|
These raw SQL hashes are not the platform ledger's normalized-statement
|
|
24136
25535
|
checksums. Building or copying an archive never applies SQL, proves compatibility
|
|
@@ -24157,6 +25556,16 @@ authenticated upload requests return 501. With that setting, SUPACLOUD_URL and
|
|
|
24157
25556
|
SUPACLOUD_SERVICE_ROLE_KEY are required; shipped adapters register/bind uploads
|
|
24158
25557
|
and enqueue attachment verification through afterApproved in the approval
|
|
24159
25558
|
transaction. A separately delivered worker consumes it.
|
|
25559
|
+
With attachments enabled, GET /reviews/:id/attachment-task returns the current
|
|
25560
|
+
authorized task state. POST /reviews/:id/attachment-task/retry takes expectedVersion,
|
|
25561
|
+
expectedRunId and a new recoveryId UUID. Reuse that same body/recoveryId after an
|
|
25562
|
+
uncertain response; do not generate another key until the outcome is reconciled.
|
|
25563
|
+
Only failed runs can be retried. A retry creates a linked new run, receipt and audit
|
|
25564
|
+
in one transaction, preserving the old run and its attempts. Concurrent distinct
|
|
25565
|
+
recovery requests cannot both replace the same failed run. Read status after the
|
|
25566
|
+
receipt: its queued state describes submission, not necessarily the current state.
|
|
25567
|
+
Apply migration 005 before updating either HTTP or Worker code; it extends only
|
|
25568
|
+
backend capabilities. Do not grant Workflow access to browser roles.
|
|
24160
25569
|
An HTTP listener or immutable build does not prove full-platform identity,
|
|
24161
25570
|
migration compatibility, activation, application rollback or data recovery.
|
|
24162
25571
|
|
|
@@ -24269,6 +25678,28 @@ committed idempotent result. The host's failure promise makes the executable exi
|
|
|
24269
25678
|
nonzero with bounded cleanup. Restart only after investigating an ownership or
|
|
24270
25679
|
receipt failure; repeated automatic restart can exhaust an unknown run's attempts.
|
|
24271
25680
|
|
|
25681
|
+
The delivery worker emits review-worker-execution JSON lines to stdout using
|
|
25682
|
+
src/host/review-worker-observability.ts, which re-exports the shared observer
|
|
25683
|
+
from src/host/review-execution-observability.ts. Events contain timestamp, workerId,
|
|
25684
|
+
kind, operation, stage, phase and optional requestId/durationMs. For attachment
|
|
25685
|
+
jobs, requestId is the Workflow run ID; join it to starter_attachments.run_id
|
|
25686
|
+
or the current starter_attachment_retries lineage, not to an HTTP request ID.
|
|
25687
|
+
Only selected execution metadata is serialized, never input, output, errors,
|
|
25688
|
+
credentials or database parameters. Correlation identifiers must be opaque,
|
|
25689
|
+
non-secret identifiers; malformed identifiers and invalid durations are omitted.
|
|
25690
|
+
Logging is best effort and cannot alter a job outcome. A succeeded stage is not
|
|
25691
|
+
proof of queue settlement: confirm the Workflow receipt and durable attachment
|
|
25692
|
+
result separately. Configure stdout collection in the deployment environment;
|
|
25693
|
+
the starter does not install a collector or prove gateway trace propagation.
|
|
25694
|
+
|
|
25695
|
+
The HTTP delivery host uses the same serializer for review-http-execution events
|
|
25696
|
+
with component review-http. Route and command events preserve their requestId
|
|
25697
|
+
from the existing request context and do not contain a workerId. Do not equate
|
|
25698
|
+
that request ID with a Workflow run ID or an idempotency key. Identity resolution
|
|
25699
|
+
and failures before an execution observer is reached are not covered by these
|
|
25700
|
+
events; use the platform's existing request/identity logs for those failures.
|
|
25701
|
+
Both hosts emit only execution metadata, not access logs or audit receipts.
|
|
25702
|
+
|
|
24272
25703
|
The memory demo does not start this worker. The included unit tests exercise the
|
|
24273
25704
|
handler contract, not production Storage, queue delivery or external identity.
|
|
24274
25705
|
|
|
@@ -24286,6 +25717,14 @@ Context packs include directional dependencies, relevant aspect files, diagnosti
|
|
|
24286
25717
|
and static execution plans, without provider values or source expressions.
|
|
24287
25718
|
The optional --events example expects a trusted host
|
|
24288
25719
|
to capture approved onExecution metadata in { "version": 1, "events": [...] }.
|
|
25720
|
+
It also accepts a bounded JSON-lines export containing only the generated
|
|
25721
|
+
review-http-execution and review-worker-execution records. Pass that file
|
|
25722
|
+
directly as --events execution.ndjson; use the HTTP request ID or Workflow run
|
|
25723
|
+
ID appropriate to the selected operation. Unknown log records, extra fields,
|
|
25724
|
+
malformed lines and oversized files fail the entire read rather than being
|
|
25725
|
+
silently skipped. Wrapper timestamps, component names and worker IDs are
|
|
25726
|
+
validated then excluded from the context metadata. Review the export before
|
|
25727
|
+
sharing; context correlation still does not authenticate the log source.
|
|
24289
25728
|
It selects one opaque request ID, omits payloads and source expressions, and applies
|
|
24290
25729
|
input/output size limits. It does not read arbitrary logs, prove deployment versions,
|
|
24291
25730
|
identify a root cause or apply repairs. Never put credentials or business data in
|
|
@@ -25013,26 +26452,47 @@ async function createVerificationPlan(root, graph, target, sourceRoot = root) {
|
|
|
25013
26452
|
const sources = [
|
|
25014
26453
|
owner.file,
|
|
25015
26454
|
...owner.providers.map((item) => item.file),
|
|
25016
|
-
...owner.controllers.map((item) => item.file)
|
|
26455
|
+
...owner.controllers.map((item) => item.file),
|
|
26456
|
+
...graph.moduleHandlerFiles?.[owner.name] ?? [],
|
|
26457
|
+
...(owner.aspects ?? []).flatMap((item) => item.file ? [item.file] : []),
|
|
26458
|
+
...owner.commands.flatMap((item) => (item.aspects ?? []).flatMap((aspect) => aspect.file ? [aspect.file] : [])),
|
|
26459
|
+
...(owner.jobs ?? []).flatMap((item) => (item.aspects ?? []).flatMap((aspect) => aspect.file ? [aspect.file] : [])),
|
|
26460
|
+
...owner.controllers.flatMap((item) => item.routes.flatMap((route) => (route.aspects ?? []).flatMap((aspect) => aspect.file ? [aspect.file] : [])))
|
|
25017
26461
|
].filter((file) => typeof file === "string");
|
|
25018
26462
|
const directories = [...new Set(sources.map((file) => dirname6(resolve15(sourceRoot, file))))].sort();
|
|
25019
26463
|
const tests = new Set;
|
|
25020
26464
|
const manual = [];
|
|
26465
|
+
const coverage = [];
|
|
25021
26466
|
for (const directory of directories) {
|
|
25022
26467
|
if (!inside2(requestedRoot, directory)) {
|
|
25023
26468
|
manual.push("A source directory is outside the project; select its owning project explicitly");
|
|
25024
26469
|
continue;
|
|
25025
26470
|
}
|
|
25026
|
-
const actual = await realpath3(directory)
|
|
26471
|
+
const actual = await realpath3(directory).catch((error) => {
|
|
26472
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
26473
|
+
return null;
|
|
26474
|
+
throw error;
|
|
26475
|
+
});
|
|
26476
|
+
if (actual === null) {
|
|
26477
|
+
manual.push(`Source directory is missing: ${relative5(requestedRoot, directory).replaceAll("\\", "/")}`);
|
|
26478
|
+
continue;
|
|
26479
|
+
}
|
|
25027
26480
|
if (!inside2(project, actual)) {
|
|
25028
26481
|
manual.push("A source directory is outside the project; select its owning project explicitly");
|
|
25029
26482
|
continue;
|
|
25030
26483
|
}
|
|
26484
|
+
const local = [];
|
|
25031
26485
|
for (const entry of await readdir4(actual, { withFileTypes: true })) {
|
|
25032
26486
|
if (entry.isFile() && /\.(test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
25033
|
-
|
|
26487
|
+
const file = relative5(project, resolve15(actual, entry.name)).replaceAll("\\", "/");
|
|
26488
|
+
tests.add(file);
|
|
26489
|
+
local.push(file);
|
|
25034
26490
|
}
|
|
25035
26491
|
}
|
|
26492
|
+
const path = relative5(project, actual).replaceAll("\\", "/") || ".";
|
|
26493
|
+
coverage.push({ directory: path, tests: local.sort(), basis: "colocated-not-proven" });
|
|
26494
|
+
if (!local.length)
|
|
26495
|
+
manual.push(`No colocated tests for source directory: ${path}`);
|
|
25036
26496
|
}
|
|
25037
26497
|
const selected = [...tests].sort();
|
|
25038
26498
|
if (!selected.length)
|
|
@@ -25051,6 +26511,12 @@ async function createVerificationPlan(root, graph, target, sourceRoot = root) {
|
|
|
25051
26511
|
["git", "diff", "--check"]
|
|
25052
26512
|
] : [],
|
|
25053
26513
|
manual,
|
|
26514
|
+
coverage,
|
|
26515
|
+
limitations: [
|
|
26516
|
+
"Colocation selects candidates; it does not prove behavioral coverage",
|
|
26517
|
+
"Dependent modules and database behavior require explicit integration acceptance",
|
|
26518
|
+
"No recursive scan, test execution or generated-file repair is performed"
|
|
26519
|
+
],
|
|
25054
26520
|
releaseOnly: ["generated artifact drift", "consumer types", "database/RLS and authenticated integration", "delivery read-back"],
|
|
25055
26521
|
executed: false
|
|
25056
26522
|
};
|
|
@@ -25259,6 +26725,7 @@ var REMOTE_APP_ACTIONS = {
|
|
|
25259
26725
|
"deploy-plan": "get_deploy_plan",
|
|
25260
26726
|
diff: "get_deploy_plan",
|
|
25261
26727
|
status: "get_runtime",
|
|
26728
|
+
evidence: "get_deployment_evidence",
|
|
25262
26729
|
rollback: "rollback_release",
|
|
25263
26730
|
"rollback-plan": "get_rollback_snapshot",
|
|
25264
26731
|
history: "get_history",
|
|
@@ -25603,11 +27070,49 @@ function formatModuleContextPack(pack) {
|
|
|
25603
27070
|
`);
|
|
25604
27071
|
}
|
|
25605
27072
|
async function runContext(args) {
|
|
25606
|
-
const { root, configFile, outDir, result } = await projectCompileConfig(args);
|
|
27073
|
+
const { root, sourceDir, configFile, outDir, result } = await projectCompileConfig(args);
|
|
25607
27074
|
const subject = args.target?.trim();
|
|
25608
27075
|
if (subject) {
|
|
25609
27076
|
const pack = createContextPack({ ...result.graph, diagnostics: result.diagnostics }, subject);
|
|
25610
|
-
|
|
27077
|
+
const verification = await createVerificationPlan(root, result.graph, pack.subject, sourceDir);
|
|
27078
|
+
const repairs = createDiagnosticRepairPlan(pack.diagnostics);
|
|
27079
|
+
const workflow = {
|
|
27080
|
+
root,
|
|
27081
|
+
sourceRoot: sourceDir,
|
|
27082
|
+
generatedDir: outDir,
|
|
27083
|
+
allowedDependencies: await readAllowedDependencies(root),
|
|
27084
|
+
editFiles: verification.files,
|
|
27085
|
+
referenceFiles: pack.files,
|
|
27086
|
+
verification,
|
|
27087
|
+
repairs,
|
|
27088
|
+
rules: {
|
|
27089
|
+
generatedFiles: "do-not-edit",
|
|
27090
|
+
permissions: "explicit-business-input-required",
|
|
27091
|
+
writeRetry: "disabled",
|
|
27092
|
+
fixDefault: "preview"
|
|
27093
|
+
},
|
|
27094
|
+
daily: [
|
|
27095
|
+
["supacloud", "app", "doctor", "--target", pack.subject, "--format", "json"],
|
|
27096
|
+
["supacloud", "app", "fix", "--fix", "<reviewed-fix.json>"],
|
|
27097
|
+
["supacloud", "app", "compile"],
|
|
27098
|
+
...verification.commands
|
|
27099
|
+
],
|
|
27100
|
+
release: {
|
|
27101
|
+
first: ["supacloud", "app", "check", "--format", "json"],
|
|
27102
|
+
rule: "Check committed artifacts before any regeneration; never repair drift to make this gate pass",
|
|
27103
|
+
additionalEvidence: verification.releaseOnly
|
|
27104
|
+
},
|
|
27105
|
+
executed: false
|
|
27106
|
+
};
|
|
27107
|
+
return textResult(args.format === "json" ? JSON.stringify({ ...pack, workflow }, null, 2) : [
|
|
27108
|
+
formatModuleContextPack(pack),
|
|
27109
|
+
` edit files (project-relative): ${workflow.editFiles.join(", ") || "-"}`,
|
|
27110
|
+
` focused tests: ${verification.tests.join(", ") || "-"}`,
|
|
27111
|
+
...verification.manual.map((item) => ` manual: ${item}`),
|
|
27112
|
+
" fixes: preview first; business policy requires explicit input",
|
|
27113
|
+
" release: app check before regeneration; local tests do not prove deployment"
|
|
27114
|
+
].join(`
|
|
27115
|
+
`));
|
|
25611
27116
|
}
|
|
25612
27117
|
const project = {
|
|
25613
27118
|
version: 1,
|
|
@@ -25694,8 +27199,10 @@ async function findUnwiredContracts(rootDir) {
|
|
|
25694
27199
|
}
|
|
25695
27200
|
async function runDoctor(args) {
|
|
25696
27201
|
const { root, outDir, result } = await projectCompileConfig(args);
|
|
25697
|
-
const
|
|
25698
|
-
const
|
|
27202
|
+
const target = args.target?.trim();
|
|
27203
|
+
const context = target ? createContextPack({ ...result.graph, diagnostics: result.diagnostics }, target) : undefined;
|
|
27204
|
+
const doctor = doctorProject(root, outDir, { ...result.graph, ...context ? { modules: context.modules } : {}, diagnostics: [] }, result.upToDate, context?.diagnostics ?? result.diagnostics);
|
|
27205
|
+
const database = context ? null : checkAppDatabaseSources(root);
|
|
25699
27206
|
if (database) {
|
|
25700
27207
|
doctor.checks.push(database);
|
|
25701
27208
|
if (!database.ok)
|
|
@@ -25705,11 +27212,29 @@ async function runDoctor(args) {
|
|
|
25705
27212
|
const autoFixable = fixPlan.filter((repair) => repair.readiness === "preview").length;
|
|
25706
27213
|
const inputRequired = fixPlan.filter((repair) => repair.readiness === "input-required").length;
|
|
25707
27214
|
const manualFixes = fixPlan.filter((repair) => repair.readiness === "manual").length;
|
|
25708
|
-
const unwiredContracts = await findUnwiredContracts(root);
|
|
27215
|
+
const unwiredContracts = context ? [] : await findUnwiredContracts(root);
|
|
27216
|
+
const scope = context ? {
|
|
27217
|
+
target: context.subject,
|
|
27218
|
+
files: context.files,
|
|
27219
|
+
omittedProjectDiagnostics: result.diagnostics.length - context.diagnostics.length,
|
|
27220
|
+
notChecked: ["unwired contracts outside graph", "additional database source CLI check"],
|
|
27221
|
+
generatedArtifactCheck: "project-wide"
|
|
27222
|
+
} : undefined;
|
|
25709
27223
|
if (args.format === "json") {
|
|
25710
|
-
return textResult(JSON.stringify({
|
|
27224
|
+
return textResult(JSON.stringify({
|
|
27225
|
+
ok: doctor.errors === 0,
|
|
27226
|
+
autoFixable,
|
|
27227
|
+
inputRequired,
|
|
27228
|
+
manualFixes,
|
|
27229
|
+
fixPlan,
|
|
27230
|
+
unwiredContracts,
|
|
27231
|
+
...scope ? { scope } : {},
|
|
27232
|
+
...doctor
|
|
27233
|
+
}, null, 2), doctor.errors > 0);
|
|
25711
27234
|
}
|
|
25712
27235
|
const lines = doctor.checks.map((check) => `${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`);
|
|
27236
|
+
if (scope)
|
|
27237
|
+
lines.unshift(`TARGET ${scope.target}; omitted project diagnostics=${scope.omittedProjectDiagnostics}; generated drift remains project-wide`);
|
|
25713
27238
|
for (const diagnostic of doctor.diagnostics ?? []) {
|
|
25714
27239
|
lines.push(formatDiagnostic(diagnostic));
|
|
25715
27240
|
}
|
|
@@ -26700,6 +28225,21 @@ async function runAppTool(request, options = {}) {
|
|
|
26700
28225
|
};
|
|
26701
28226
|
validateToolArguments2(APPLICATION_TOOL_SCHEMA2, args);
|
|
26702
28227
|
const result = await delegate(args);
|
|
28228
|
+
if (request.action === "evidence" && format !== "json" && json !== true && !result.isError) {
|
|
28229
|
+
try {
|
|
28230
|
+
const payload = JSON.parse(result.content[0].text);
|
|
28231
|
+
if (payload === null || typeof payload !== "object" || !("project_ref" in payload) || payload.project_ref !== args.ref || !("application_id" in payload) || payload.application_id !== request.id || !("environment_id" in payload) || payload.environment_id !== request.environment_id || !("evidence" in payload))
|
|
28232
|
+
throw new Error;
|
|
28233
|
+
if (payload.evidence === null)
|
|
28234
|
+
return textResult("DEPLOYMENT incomplete: no recorded evidence. Read runtime status and obtain authenticated smoke and recovery evidence.");
|
|
28235
|
+
const evidence = parseDeploymentEvidence(payload.evidence);
|
|
28236
|
+
if (evidence.scope.project_ref !== args.ref || evidence.scope.application_id !== request.id || evidence.scope.environment_id !== request.environment_id)
|
|
28237
|
+
throw new Error;
|
|
28238
|
+
return textResult(formatDeploymentEvidence(evidence));
|
|
28239
|
+
} catch {
|
|
28240
|
+
return textResult("DEPLOYMENT unknown: invalid or mismatched evidence; no mutation was attempted.", true);
|
|
28241
|
+
}
|
|
28242
|
+
}
|
|
26703
28243
|
if (request.action === "history" && format !== "json" && json !== true) {
|
|
26704
28244
|
return historyText(result, request, args.ref);
|
|
26705
28245
|
}
|
|
@@ -26792,6 +28332,7 @@ function registerAppTools(server, options = {}) {
|
|
|
26792
28332
|
"deploy-plan",
|
|
26793
28333
|
"diff",
|
|
26794
28334
|
"status",
|
|
28335
|
+
"evidence",
|
|
26795
28336
|
"rollback",
|
|
26796
28337
|
"rollback-plan",
|
|
26797
28338
|
"history",
|
|
@@ -30562,7 +32103,7 @@ async function runProjectDeployment(http, args) {
|
|
|
30562
32103
|
// package.json
|
|
30563
32104
|
var package_default = {
|
|
30564
32105
|
name: "@supacloud/cli",
|
|
30565
|
-
version: "0.
|
|
32106
|
+
version: "0.103.1",
|
|
30566
32107
|
description: "Project-scoped CLI for SupaCloud users",
|
|
30567
32108
|
type: "module",
|
|
30568
32109
|
main: "./dist/index.js",
|
|
@@ -30614,18 +32155,19 @@ var package_default = {
|
|
|
30614
32155
|
directory: "packages/cli"
|
|
30615
32156
|
},
|
|
30616
32157
|
dependencies: {
|
|
30617
|
-
"@supacloud/compiler": "^0.46.
|
|
30618
|
-
"@supacloud/delivery": "0.
|
|
30619
|
-
"@supacloud/db": "0.
|
|
32158
|
+
"@supacloud/compiler": "^0.46.5",
|
|
32159
|
+
"@supacloud/delivery": "0.19.0",
|
|
32160
|
+
"@supacloud/db": "0.18.0",
|
|
30620
32161
|
typebox: "1.3.37"
|
|
30621
32162
|
},
|
|
30622
32163
|
overrides: {
|
|
30623
|
-
"@supacloud/contracts": "0.11.
|
|
30624
|
-
"@supacloud/delivery": "0.
|
|
32164
|
+
"@supacloud/contracts": "0.11.1",
|
|
32165
|
+
"@supacloud/delivery": "0.19.0",
|
|
30625
32166
|
"@graphql-tools/utils": "^12.0.4"
|
|
30626
32167
|
},
|
|
30627
32168
|
devDependencies: {
|
|
30628
|
-
"@
|
|
32169
|
+
"@supabase/supabase-js": "2.117.3",
|
|
32170
|
+
"@supacloud/elysia": "0.33.1",
|
|
30629
32171
|
"@types/bun": "^1.4.3",
|
|
30630
32172
|
effect: "4.0.5",
|
|
30631
32173
|
elysia: "2.0.0-beta.32",
|
|
@@ -30891,6 +32433,7 @@ EXAMPLES
|
|
|
30891
32433
|
${preferredCommand} app diff --ref abc123 --id orders --environment_id test --release_id <sha256> --configuration_id <uuid>
|
|
30892
32434
|
${preferredCommand} app deploy --ref abc123 --id orders --environment_id test --release_id <sha256> --configuration_id <uuid>
|
|
30893
32435
|
${preferredCommand} app status --ref abc123 --id orders --environment_id test
|
|
32436
|
+
${preferredCommand} app evidence --ref abc123 --id orders --environment_id test
|
|
30894
32437
|
${preferredCommand} app diff --ref abc123 --id orders --environment_id production --source_ref staging --source_environment_id staging --source_release_id <sha256>
|
|
30895
32438
|
${preferredCommand} --env production app promote-plan --id orders --environment_id production --source_ref staging --source_environment_id staging --source_release_id <sha256>
|
|
30896
32439
|
${preferredCommand} app preview-plan --ref abc123 --id orders --environment_id test --release_id <sha256> --branch_ref preview-orders
|