@akanjs/devkit 3.2.0-rc.7 → 3.2.0-rc.8

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.
@@ -44,6 +44,7 @@ export interface TargetOption {
44
44
  runsOnWorkspaceRoot?: boolean;
45
45
  stdio?: boolean;
46
46
  failureExitCode?: number;
47
+ /** The exit of a `usage` or `unknownTarget` failure, for a command whose 2 already answers something else. */
47
48
  usageExitCode?: number;
48
49
  /** It deletes files in its app, lib, pkg or module, so one left out or unknown is refused, never picked or asked. */
49
50
  destructive?: boolean;
@@ -68,6 +68,31 @@ describe("IncrementalBuilderHost", () => {
68
68
  host.stop();
69
69
  });
70
70
 
71
+ test("a stop while a restart is pending leaves the builder down once the delay passes", async () => {
72
+ const { spawns, host } = startRecordingHost();
73
+ spawns[0]?.options.onExit?.();
74
+ expect(host.status).toBe("restarting");
75
+
76
+ host.stop();
77
+ await wait(1_050);
78
+ expect(spawns).toHaveLength(1);
79
+ expect(host.status).toBe("stopped");
80
+ });
81
+
82
+ test("a builder that crashes again after coming back ready waits the first delay again", async () => {
83
+ const { spawns, host } = startRecordingHost();
84
+ spawns[0]?.options.onExit?.();
85
+ await wait(1_050);
86
+ expect(spawns).toHaveLength(2);
87
+
88
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
89
+ spawns[1]?.options.onExit?.();
90
+ await wait(1_050);
91
+ expect(spawns).toHaveLength(3);
92
+
93
+ host.stop();
94
+ });
95
+
71
96
  test("recycles a ready builder gracefully and replaces it immediately", async () => {
72
97
  const spawns = mockSpawns();
73
98
  const onRestartReady = mock();
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { Logger } from "akanjs/common";
3
+ import { Logger, RestartSchedule } from "akanjs/common";
4
4
  import type { BuilderMessage, BuilderRequest } from "akanjs/server";
5
5
  import { CSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
6
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
@@ -49,8 +49,6 @@ interface IncrementalBuilderStartOptions {
49
49
  }
50
50
 
51
51
  export class IncrementalBuilderHost {
52
- static readonly #restartBaseDelayMs = 1_000;
53
- static readonly #restartMaxDelayMs = 30_000;
54
52
  static readonly #recycleDrainTimeoutMs = 30_000;
55
53
  // apps/akan's builder peaks near 950MB holding the SSR registry's patcher through a route build, and arming CSR adds
56
54
  // a second patcher: this recycles only a builder grown well past both.
@@ -65,8 +63,7 @@ export class IncrementalBuilderHost {
65
63
  readonly #onOutput: ((kind: "stdout" | "stderr", text: string) => void) | null;
66
64
  #proc: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
67
65
  #status: IncrementalBuilderStatus = "stopped";
68
- #restartAttempts = 0;
69
- #restartTimer: ReturnType<typeof setTimeout> | null = null;
66
+ readonly #restart = new RestartSchedule({ min: 1_000, max: 30_000 });
70
67
  #recycleTimer: ReturnType<typeof setTimeout> | null = null;
71
68
  #recycleRequested: boolean = false;
72
69
  #spawnAfterRecycle: boolean = false;
@@ -136,7 +133,7 @@ export class IncrementalBuilderHost {
136
133
  if (msg.type === "builder-ready" && !this.ready) {
137
134
  this.ready = true;
138
135
  this.#status = "ready";
139
- this.#restartAttempts = 0;
136
+ this.#restart.reset();
140
137
  if (isRestart) this.#startOptions.onRestartReady?.();
141
138
  else this.#startOptions.onReady?.();
142
139
  }
@@ -210,20 +207,13 @@ export class IncrementalBuilderHost {
210
207
  }
211
208
  }
212
209
  #scheduleRestart() {
213
- if (this.#manualStop || this.#restartTimer) return;
210
+ if (this.#manualStop || this.#restart.pending) return;
214
211
  this.#status = "restarting";
215
- const attempt = this.#restartAttempts;
216
- const delay = Math.min(
217
- IncrementalBuilderHost.#restartBaseDelayMs * 2 ** attempt,
218
- IncrementalBuilderHost.#restartMaxDelayMs,
219
- );
220
- this.#restartAttempts = attempt + 1;
221
- this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restartAttempts})`);
222
- this.#restartTimer = setTimeout(() => {
223
- this.#restartTimer = null;
212
+ const delay = this.#restart.schedule(() => {
224
213
  if (this.#manualStop) return;
225
214
  this.#spawn(true);
226
- }, delay);
215
+ });
216
+ this.logger.warn(`builder exited after ready; restarting in ${delay}ms (attempt ${this.#restart.attempts})`);
227
217
  }
228
218
  /** Drain-and-exit rather than `kill()`, so no rebuild is truncated; false when the request was not sent. */
229
219
  recycle(reason: string): boolean {
@@ -294,10 +284,7 @@ export class IncrementalBuilderHost {
294
284
  this.#manualStop = true;
295
285
  this.#clearRecycle();
296
286
  this.#failInFlight("builder was stopped before answering");
297
- if (this.#restartTimer) {
298
- clearTimeout(this.#restartTimer);
299
- this.#restartTimer = null;
300
- }
287
+ this.#restart.cancel();
301
288
  if (this.#proc) this.#proc.kill();
302
289
  this.#proc = null;
303
290
  this.ready = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.2.0-rc.7",
3
+ "version": "3.2.0-rc.8",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -41,7 +41,7 @@
41
41
  "./*": "./*.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@akanjs/language": "3.2.0-rc.7",
44
+ "@akanjs/language": "3.2.0-rc.8",
45
45
  "@earendil-works/pi-agent-core": "1.3.0",
46
46
  "@earendil-works/pi-ai": "1.3.0",
47
47
  "@earendil-works/pi-codemode": "1.3.0",
@@ -50,7 +50,7 @@
50
50
  "@earendil-works/pi-tui": "1.3.0",
51
51
  "@inquirer/prompts": "^8.7.3",
52
52
  "@tailwindcss/node": "^4.3.3",
53
- "akanjs": "3.2.0-rc.7",
53
+ "akanjs": "3.2.0-rc.8",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -1,5 +1,6 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { CliError } from "../commandDecorators/cliError";
3
4
  import type { TypecheckRecord } from "../typeChecker";
4
5
  import { TypecheckFailure } from "./typecheckFailure";
5
6
 
@@ -69,7 +70,10 @@ export class ProjectTypecheck {
69
70
  if (exitCode === 0) return;
70
71
  const records = report === "records" ? ProjectTypecheck.#parseRecords(stdout) : null;
71
72
  if (records?.length) throw new TypecheckFailure(records);
72
- throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
73
+ const output = (stderr || stdout).trim();
74
+ if (exitCode === TypecheckFailure.workerExitCode && report === "pretty")
75
+ throw new CliError("checkFailed", output || "Typecheck failed");
76
+ throw new Error(output || `Typecheck failed with exit code ${exitCode}`);
73
77
  }
74
78
 
75
79
  static #parseRecords(stdout: string): TypecheckRecord[] | null {
@@ -1,5 +1,6 @@
1
1
  import { Logger } from "akanjs/common";
2
2
  import { TypeChecker } from "../typeChecker";
3
+ import { TypecheckFailure } from "./typecheckFailure";
3
4
 
4
5
  try {
5
6
  const filePath = process.env.AKAN_TYPECHECK_FILE;
@@ -28,11 +29,11 @@ try {
28
29
  //? Not process.cwd(): that is the realpath (/private/var on macOS) while TypeScript keeps the spelling it was given.
29
30
  const root = process.env.AKAN_TYPECHECK_ROOT ?? process.cwd();
30
31
  process.stdout.write(JSON.stringify(TypeChecker.toRecords(result.errors, root)));
31
- process.exit(result.errors.length > 0 ? 1 : 0);
32
+ process.exit(result.errors.length > 0 ? TypecheckFailure.workerExitCode : 0);
32
33
  }
33
34
  if (result.errors.length > 0) {
34
35
  console.error(result.message);
35
- process.exit(1);
36
+ process.exit(TypecheckFailure.workerExitCode);
36
37
  }
37
38
  } catch (error) {
38
39
  console.error(error instanceof Error ? error.message : String(error));
@@ -1,8 +1,13 @@
1
+ import { CliError } from "../commandDecorators/cliError";
1
2
  import type { TypecheckRecord } from "../typeChecker";
2
3
 
3
- export class TypecheckFailure extends Error {
4
+ export class TypecheckFailure extends CliError<"checkFailed"> {
5
+ //? The worker's exit when the project has type errors, so its crash, which exits 1, is told apart.
6
+ static readonly workerExitCode = 3;
7
+
4
8
  constructor(readonly records: TypecheckRecord[]) {
5
9
  super(
10
+ "checkFailed",
6
11
  records
7
12
  .slice(0, 20)
8
13
  .map((record) => `${record.file ?? "-"}:${record.line}:${record.col} ${record.code} ${record.message}`)
@@ -95,11 +95,11 @@ export const createWorkflowApplyReport = ({
95
95
  postApplyChecks?: WorkflowApplyReport["postApplyChecks"];
96
96
  recommendations?: WorkflowApplyReport["recommendations"];
97
97
  }): WorkflowApplyReport => {
98
- const validationCommands = recommendedValidationCommands ?? commands;
99
- const nextActionsWithIntent = nextActions.map((action) => ({
100
- ...action,
101
- action: inferNextActionCode(action, diagnostics),
102
- }));
98
+ const status = workflowStatus(diagnostics);
99
+ const validationCommands = status === "failed" ? [] : (recommendedValidationCommands ?? commands);
100
+ const nextActionsWithIntent = nextActions
101
+ .map((action) => ({ ...action, action: inferNextActionCode(action, diagnostics) }))
102
+ .filter((action) => status !== "failed" || action.action !== "validate");
103
103
  if (sourceChangeBlocked(diagnostics) && !nextActionsWithIntent.some((action) => action.action === "blocked")) {
104
104
  nextActionsWithIntent.unshift({
105
105
  command: `akan workflow explain ${workflow}`,
@@ -111,12 +111,13 @@ export const createWorkflowApplyReport = ({
111
111
  (left, right) => nextActionPriority(left, diagnostics) - nextActionPriority(right, diagnostics),
112
112
  );
113
113
  const sourceFilesChanged = uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
114
- const generatedFilesSynced = uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
114
+ const generatedFilesSynced =
115
+ status === "failed" ? [] : uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
115
116
  return {
116
117
  schemaVersion: 1,
117
118
  workflow,
118
119
  mode,
119
- status: workflowStatus(diagnostics),
120
+ status,
120
121
  summary: {
121
122
  sourceFilesChanged,
122
123
  generatedFilesSynced,
@@ -0,0 +1,92 @@
1
+ import ts from "typescript";
2
+ import type { Sys } from "../commandDecorators";
3
+ import { ensureBaseImport, ensureNamedImport, getSysRoot, moduleComponentName, moduleSourcePaths } from "./source";
4
+ import { sourceFileFor } from "./sourceAst";
5
+ import type { WorkflowDiagnostic } from "./types";
6
+
7
+ const builtInFieldTypes = new Set(["String", "Boolean", "Date", "Map"]);
8
+ const baseFieldTypes = new Set(["Int", "Float", "ID", "Any", "Binary", "ImageResult"]);
9
+ const fieldTypeReference = /^\[*([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)\]*$/;
10
+
11
+ export const ensureBaseTypeImport = (content: string, typeName: string) => {
12
+ const [, name = "", member] = fieldTypeReference.exec(typeName) ?? [];
13
+ if (member || !baseFieldTypes.has(name)) return content;
14
+ return ensureBaseImport(content, name);
15
+ };
16
+
17
+ const valueNamesOf = (content: string) => {
18
+ const names = new Set<string>();
19
+ for (const statement of sourceFileFor("source.ts", content).statements) {
20
+ if (ts.isImportDeclaration(statement)) {
21
+ const clause = statement.importClause;
22
+ if (!clause || clause.isTypeOnly) continue;
23
+ if (clause.name) names.add(clause.name.text);
24
+ const bindings = clause.namedBindings;
25
+ if (bindings && ts.isNamespaceImport(bindings)) names.add(bindings.name.text);
26
+ else for (const element of bindings?.elements ?? []) if (!element.isTypeOnly) names.add(element.name.text);
27
+ } else if (ts.isVariableStatement(statement)) {
28
+ for (const declaration of statement.declarationList.declarations)
29
+ if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text);
30
+ } else if (
31
+ (ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isFunctionDeclaration(statement)) &&
32
+ statement.name
33
+ )
34
+ names.add(statement.name.text);
35
+ }
36
+ return names;
37
+ };
38
+
39
+ export const fieldTypeSource = async (
40
+ sys: Sys,
41
+ module: string,
42
+ typeName: string,
43
+ constantContent: string,
44
+ ): Promise<{ import?: { name: string; from: string }; diagnostic?: WorkflowDiagnostic }> => {
45
+ const reference = fieldTypeReference.exec(typeName);
46
+ if (!reference)
47
+ return {
48
+ diagnostic: {
49
+ severity: "error",
50
+ code: "primitive-field-type-unknown",
51
+ input: "type",
52
+ message: `Field type "${typeName}" is not a type name field() takes. Name one type, such as Address, or a list of one, such as [Address], then plan this again.`,
53
+ },
54
+ };
55
+ const [, name = "", member] = reference;
56
+ if (builtInFieldTypes.has(name) || baseFieldTypes.has(name) || valueNamesOf(constantContent).has(name)) return {};
57
+ const [scalars, models] = await Promise.all([sys.getScalarModules(), sys.getDatabaseModules()]);
58
+ const scalar = member ? undefined : scalars.find((scalarName) => moduleComponentName(scalarName) === name);
59
+ if (scalar) return { import: { name, from: `../__scalar/${scalar}/${scalar}.constant` } };
60
+ const model = member ? undefined : models.find((modelName) => moduleComponentName(modelName) === name);
61
+ if (model) return { import: { name, from: `../${model}/${model}.constant` } };
62
+ const root = getSysRoot(sys);
63
+ const constantPath = `${root}/${moduleSourcePaths(module).constant}`;
64
+ const named = (kind: string, names: string[]) =>
65
+ names.length ? `Its ${kind} are ${names.map(moduleComponentName).join(", ")}.` : `It has no ${kind}.`;
66
+ return {
67
+ diagnostic: {
68
+ severity: "error",
69
+ code: "primitive-field-type-unknown",
70
+ input: "type",
71
+ message: [
72
+ `Field type "${typeName}" names ${name}, which is not a built-in or akanjs/base type, not imported by ${constantPath}, and not a scalar or model in ${root}.`,
73
+ named("scalars", scalars),
74
+ named("models", models),
75
+ `Create the scalar with the create-scalar workflow first, or import ${name} into ${constantPath}, then plan this again.`,
76
+ ].join(" "),
77
+ },
78
+ };
79
+ };
80
+
81
+ export const withFieldTypeImport = async (
82
+ sys: Sys,
83
+ module: string,
84
+ typeName: string,
85
+ content: string,
86
+ diagnostics: WorkflowDiagnostic[],
87
+ ) => {
88
+ const withBaseImport = ensureBaseTypeImport(content, typeName);
89
+ const source = await fieldTypeSource(sys, module, typeName, withBaseImport);
90
+ if (source.diagnostic) diagnostics.push(source.diagnostic);
91
+ return source.import ? ensureNamedImport(withBaseImport, source.import.from, source.import.name) : withBaseImport;
92
+ };
package/workflow/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./artifacts";
2
2
  export * from "./executor";
3
+ export * from "./fieldType";
3
4
  export * from "./guardCatalog";
4
5
  export * from "./interactionScaffold";
5
6
  export * from "./keyRename";
package/workflow/plan.ts CHANGED
@@ -2,11 +2,12 @@ import { capitalize } from "akanjs/common";
2
2
  import type { Sys, Workspace } from "../commandDecorators";
3
3
  import { withoutApplyHints, workflowPlanApproval } from "./artifacts";
4
4
  import { resolveWorkflowSys } from "./executor";
5
+ import { fieldTypeSource } from "./fieldType";
5
6
  import { InteractionScaffold } from "./interactionScaffold";
6
7
  import { KeyRename } from "./keyRename";
7
8
  import { MutationScaffold } from "./mutationScaffold";
8
9
  import { identifierPattern, sysNamePattern } from "./plainName";
9
- import { coerceFieldDefault, getSysRoot, moduleSourcePaths } from "./source";
10
+ import { coerceFieldDefault, getSysRoot, moduleSourcePaths, normalizeFieldType } from "./source";
10
11
  import type {
11
12
  WorkflowDiagnostic,
12
13
  WorkflowInputSpec,
@@ -541,6 +542,13 @@ const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string):
541
542
  };
542
543
  };
543
544
 
545
+ const fieldTypeUnknown = async (sys: Sys, spec: WorkflowSpec, module: string, type: string | null) => {
546
+ if (spec.name !== "add-field" || !type || ["enum", "number", "numeric", "upload"].includes(type.toLowerCase()))
547
+ return null;
548
+ const constant = await sys.readFile(moduleSourcePaths(module).constant);
549
+ return (await fieldTypeSource(sys, module, normalizeFieldType(type), constant)).diagnostic ?? null;
550
+ };
551
+
544
552
  export const workflowTargetDiagnostics = async (
545
553
  workspace: Workspace,
546
554
  spec: WorkflowSpec,
@@ -563,7 +571,10 @@ export const workflowTargetDiagnostics = async (
563
571
  const module = planString(inputs, "module");
564
572
  const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
565
573
  if (!module || !moduleSpec?.mustExist) return [];
566
- const refused = (await moduleMissing(workspace, sys, module)) ?? (await moduleNotDatabase(sys, spec, module));
574
+ const refused =
575
+ (await moduleMissing(workspace, sys, module)) ??
576
+ (await moduleNotDatabase(sys, spec, module)) ??
577
+ (await fieldTypeUnknown(sys, spec, module, planString(inputs, "type")));
567
578
  return refused ? [refused] : [];
568
579
  };
569
580
 
@@ -238,11 +238,6 @@ export const ensureImportDeclaration = (content: string, moduleSpecifier: string
238
238
 
239
239
  export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
240
240
 
241
- export const ensureBaseTypeImport = (content: string, typeName: string) => {
242
- if (typeName !== "Int" && typeName !== "Float") return content;
243
- return ensureBaseImport(content, typeName);
244
- };
245
-
246
241
  export type FieldDefaultValue = string | number | boolean | null;
247
242
 
248
243
  const numericDefault = (typeName: "Int" | "Float", rawDefault: FieldDefaultValue): string | null => {