@akanjs/devkit 2.3.11-rc.1 → 2.3.11-rc.10

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.
@@ -403,6 +403,8 @@ export const createWorkflowStepRegistry = ({
403
403
  createUi,
404
404
  addField,
405
405
  addEnumField,
406
+ addMutation,
407
+ addSlice,
406
408
  }: WorkflowPrimitiveOperations): WorkflowStepRegistry => {
407
409
  const inspect = async () => undefined;
408
410
  const commandOnly = async () => undefined;
@@ -483,6 +485,24 @@ export const createWorkflowStepRegistry = ({
483
485
  ),
484
486
  [workflowStepKey("add-enum-field", "update-dictionary")]: inspect,
485
487
  [workflowStepKey("add-enum-field", "update-option")]: inspect,
488
+ [workflowStepKey("add-mutation", "update-service")]: async (_step, plan) =>
489
+ primitiveReportToWorkflowStepResult(
490
+ await addMutation({
491
+ app: workflowStringInput(plan.inputs.app),
492
+ module: workflowStringInput(plan.inputs.module),
493
+ mutation: workflowStringInput(plan.inputs.mutation),
494
+ }),
495
+ ),
496
+ [workflowStepKey("add-mutation", "update-signal")]: inspect,
497
+ [workflowStepKey("add-slice", "update-service-query")]: async (_step, plan) =>
498
+ primitiveReportToWorkflowStepResult(
499
+ await addSlice({
500
+ app: workflowStringInput(plan.inputs.app),
501
+ module: workflowStringInput(plan.inputs.module),
502
+ slice: workflowStringInput(plan.inputs.slice),
503
+ }),
504
+ ),
505
+ [workflowStepKey("add-slice", "update-signal-slice")]: inspect,
486
506
  };
487
507
  };
488
508
 
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { inspectDictionaryStructure } from "./source";
2
+ import {
3
+ hasClassMethod,
4
+ hasSignalFactoryEntry,
5
+ hasSourceParseErrors,
6
+ insertClassMethod,
7
+ insertSignalFactoryEntry,
8
+ inspectDictionaryStructure,
9
+ } from "./source";
3
10
 
4
11
  describe("inspectDictionaryStructure", () => {
5
12
  test("preserves protected dictionary chain order around the model object", () => {
@@ -60,3 +67,109 @@ export const dictionary = modelDictionary(["en", "ko"])
60
67
  expect(structure.chainMethods).toEqual(["modelDictionary", "slice", "model", "translate", "error"]);
61
68
  });
62
69
  });
70
+
71
+ describe("insertClassMethod", () => {
72
+ test("inserts a method into an empty service class body", () => {
73
+ const content = `import { serve } from "akanjs/service";
74
+ import * as db from "../db";
75
+ export class BannerService extends serve(db.banner, () => ({})) {}
76
+ `;
77
+ const next = insertClassMethod(content, "BannerService", " async archive() {\n return true;\n }");
78
+ expect(next).not.toBeNull();
79
+ expect(hasSourceParseErrors(next as string, "service.ts")).toBe(false);
80
+ expect(hasClassMethod(next as string, "BannerService", "archive")).toBe(true);
81
+ });
82
+
83
+ test("inserts a method into a populated service class body", () => {
84
+ const content = `import { serve } from "akanjs/service";
85
+ import * as db from "../db";
86
+ export class NotificationService extends serve(db.notification, () => ({})) {
87
+ async subscribe(token: string) {
88
+ return token;
89
+ }
90
+ }
91
+ `;
92
+ const next = insertClassMethod(content, "NotificationService", " async archive() {\n return true;\n }");
93
+ expect(next).not.toBeNull();
94
+ expect(hasSourceParseErrors(next as string, "service.ts")).toBe(false);
95
+ expect(hasClassMethod(next as string, "NotificationService", "archive")).toBe(true);
96
+ expect(hasClassMethod(next as string, "NotificationService", "subscribe")).toBe(true);
97
+ });
98
+
99
+ test("returns null when the class is not found", () => {
100
+ expect(insertClassMethod("export class Other {}", "MissingService", " x() {}")).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe("insertSignalFactoryEntry", () => {
105
+ const mutationEntry =
106
+ "archive: mutation(Boolean)\n .exec(async function () {\n return await this.bannerService.archive();\n }),";
107
+
108
+ test("adds mutation param when the endpoint factory has no params", () => {
109
+ const content = `import { endpoint } from "akanjs/signal";
110
+ import * as srv from "../srv";
111
+ export class BannerEndpoint extends endpoint(srv.banner, () => ({})) {}
112
+ `;
113
+ const next = insertSignalFactoryEntry(content, "BannerEndpoint", "archive", mutationEntry, {
114
+ mode: "destructure",
115
+ name: "mutation",
116
+ });
117
+ expect(next).not.toBeNull();
118
+ expect(hasSourceParseErrors(next as string, "signal.ts")).toBe(false);
119
+ expect(hasSignalFactoryEntry(next as string, "BannerEndpoint", "archive")).toBe(true);
120
+ expect(next as string).toContain("{ mutation }");
121
+ });
122
+
123
+ test("extends an existing destructured param without dropping siblings", () => {
124
+ const content = `import { endpoint } from "akanjs/signal";
125
+ import * as srv from "../srv";
126
+ export class BannerEndpoint extends endpoint(srv.banner, ({ pubsub, query }) => ({
127
+ existing: query(String).exec(async function () {
128
+ return "x";
129
+ }),
130
+ })) {}
131
+ `;
132
+ const next = insertSignalFactoryEntry(content, "BannerEndpoint", "archive", mutationEntry, {
133
+ mode: "destructure",
134
+ name: "mutation",
135
+ });
136
+ expect(next).not.toBeNull();
137
+ expect(hasSourceParseErrors(next as string, "signal.ts")).toBe(false);
138
+ expect(next as string).toContain("{ pubsub, query, mutation }");
139
+ expect(hasSignalFactoryEntry(next as string, "BannerEndpoint", "archive")).toBe(true);
140
+ expect(hasSignalFactoryEntry(next as string, "BannerEndpoint", "existing")).toBe(true);
141
+ });
142
+
143
+ test("adds the init param to a slice factory and inserts the slice entry", () => {
144
+ const content = `import { slice, Admin, Public } from "akanjs/signal";
145
+ import * as srv from "../srv";
146
+ export class BannerSlice extends slice(srv.banner, { guards: { root: Admin, get: Public, cru: Admin } }, () => ({})) {}
147
+ `;
148
+ const sliceEntry =
149
+ "inPublic: init()\n .exec(function () {\n return this.bannerService.queryInPublic();\n }),";
150
+ const next = insertSignalFactoryEntry(content, "BannerSlice", "inPublic", sliceEntry, {
151
+ mode: "positional",
152
+ name: "init",
153
+ });
154
+ expect(next).not.toBeNull();
155
+ expect(hasSourceParseErrors(next as string, "signal.ts")).toBe(false);
156
+ expect(next as string).toContain("(init) =>");
157
+ expect(hasSignalFactoryEntry(next as string, "BannerSlice", "inPublic")).toBe(true);
158
+ });
159
+
160
+ test("is idempotent when the entry already exists", () => {
161
+ const content = `import { endpoint } from "akanjs/signal";
162
+ import * as srv from "../srv";
163
+ export class BannerEndpoint extends endpoint(srv.banner, ({ mutation }) => ({
164
+ archive: mutation(Boolean).exec(async function () {
165
+ return true;
166
+ }),
167
+ })) {}
168
+ `;
169
+ const next = insertSignalFactoryEntry(content, "BannerEndpoint", "archive", mutationEntry, {
170
+ mode: "destructure",
171
+ name: "mutation",
172
+ });
173
+ expect(next).toBe(content);
174
+ });
175
+ });
@@ -862,3 +862,134 @@ export const parseValues = (value: string | null) =>
862
862
  ?.split(",")
863
863
  .map((item) => item.trim())
864
864
  .filter(Boolean) ?? [];
865
+
866
+ // ---- Service / signal insertion (add-mutation, add-slice) ----
867
+
868
+ export const hasSourceParseErrors = (content: string, fileName = "source.ts") =>
869
+ hasParseDiagnostics(sourceFileFor(fileName, content));
870
+
871
+ const findClassDeclaration = (source: ts.SourceFile, className: string): ts.ClassDeclaration | null => {
872
+ let found: ts.ClassDeclaration | null = null;
873
+ const visit = (node: ts.Node) => {
874
+ if (found) return;
875
+ if (ts.isClassDeclaration(node) && node.name?.text === className) {
876
+ found = node;
877
+ return;
878
+ }
879
+ ts.forEachChild(node, visit);
880
+ };
881
+ ts.forEachChild(source, visit);
882
+ return found;
883
+ };
884
+
885
+ const firstHeritageCall = (node: ts.ClassDeclaration): ts.CallExpression | null => {
886
+ const expression = (node.heritageClauses?.flatMap((clause) => [...clause.types]) ?? [])[0]?.expression;
887
+ return expression && ts.isCallExpression(expression) ? expression : null;
888
+ };
889
+
890
+ const factoryArrowOf = (call: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | null => {
891
+ const arg = call.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
892
+ return arg && (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) ? arg : null;
893
+ };
894
+
895
+ export const hasClassMethod = (content: string, className: string, methodName: string) => {
896
+ const node = findClassDeclaration(sourceFileFor("service.ts", content), className);
897
+ return Boolean(
898
+ node?.members.some((member) => ts.isMethodDeclaration(member) && nodeName(member.name) === methodName),
899
+ );
900
+ };
901
+
902
+ export const insertClassMethod = (content: string, className: string, methodBlock: string): string | null => {
903
+ const source = sourceFileFor("service.ts", content);
904
+ const node = findClassDeclaration(source, className);
905
+ if (!node) return null;
906
+ const closeBrace = node.getEnd() - 1;
907
+ if (content[closeBrace] !== "}") return null;
908
+ const lead = content.slice(0, closeBrace).endsWith("\n") ? "" : "\n";
909
+ return spliceText(content, closeBrace, closeBrace, `${lead}${methodBlock}\n`);
910
+ };
911
+
912
+ export interface FactoryParamPlan {
913
+ mode: "destructure" | "positional";
914
+ name: string;
915
+ }
916
+
917
+ const arrowParamInnerRegion = (
918
+ content: string,
919
+ source: ts.SourceFile,
920
+ arrow: ts.ArrowFunction | ts.FunctionExpression,
921
+ ) => {
922
+ if (arrow.parameters.length > 0) {
923
+ return {
924
+ start: arrow.parameters[0].getStart(source),
925
+ end: arrow.parameters[arrow.parameters.length - 1].getEnd(),
926
+ };
927
+ }
928
+ const open = content.indexOf("(", arrow.getStart(source));
929
+ if (open < 0) return null;
930
+ const close = content.indexOf(")", open);
931
+ if (close < 0) return null;
932
+ return { start: open + 1, end: close };
933
+ };
934
+
935
+ const factoryParamEdit = (
936
+ content: string,
937
+ source: ts.SourceFile,
938
+ arrow: ts.ArrowFunction | ts.FunctionExpression,
939
+ plan: FactoryParamPlan,
940
+ ): { start: number; end: number; text: string } | "unchanged" | null => {
941
+ if (arrow.parameters.length > 1) return null;
942
+ const region = arrowParamInnerRegion(content, source, arrow);
943
+ if (!region) return null;
944
+ if (arrow.parameters.length === 0) {
945
+ return { ...region, text: plan.mode === "destructure" ? `{ ${plan.name} }` : plan.name };
946
+ }
947
+ const param = arrow.parameters[0];
948
+ if (plan.mode === "positional") return nodeName(param.name) === plan.name ? "unchanged" : null;
949
+ if (!ts.isObjectBindingPattern(param.name)) return null;
950
+ const names = param.name.elements.map((element) => (ts.isIdentifier(element.name) ? element.name.text : null));
951
+ if (names.some((name) => name === null)) return null;
952
+ if (names.includes(plan.name)) return "unchanged";
953
+ return {
954
+ start: param.getStart(source),
955
+ end: param.getEnd(),
956
+ text: `{ ${[...(names as string[]), plan.name].join(", ")} }`,
957
+ };
958
+ };
959
+
960
+ const factoryObjectOf = (source: ts.SourceFile, className: string) => {
961
+ const node = findClassDeclaration(source, className);
962
+ const call = node ? firstHeritageCall(node) : null;
963
+ const arrow = call ? factoryArrowOf(call) : null;
964
+ const object = arrow ? firstObjectReturnedByArrow(arrow) : null;
965
+ return arrow && object ? { arrow, object } : null;
966
+ };
967
+
968
+ export const hasSignalFactoryEntry = (content: string, className: string, entryName: string) => {
969
+ const located = factoryObjectOf(sourceFileFor("signal.ts", content), className);
970
+ return Boolean(located?.object.properties.some((property) => propertyName(property) === entryName));
971
+ };
972
+
973
+ export const insertSignalFactoryEntry = (
974
+ content: string,
975
+ className: string,
976
+ entryName: string,
977
+ entryLine: string,
978
+ param: FactoryParamPlan,
979
+ ): string | null => {
980
+ const source = sourceFileFor("signal.ts", content);
981
+ const located = factoryObjectOf(source, className);
982
+ if (!located) return null;
983
+ const locator = locatedObject(source, located.object);
984
+ if (locator.fields.some((field) => field.name === entryName)) return content;
985
+ // Compute the param edit first (its offsets precede the object), but apply it last so the
986
+ // object splice (at a higher offset) does not invalidate the param region positions.
987
+ const paramEdit = factoryParamEdit(content, source, located.arrow, param);
988
+ if (paramEdit === null) return null;
989
+ const withEntry = insertOrderedFieldLine(content, locator, entryName, entryLine, {
990
+ fieldIndent: " ",
991
+ closingIndent: "",
992
+ });
993
+ if (withEntry === content) return null;
994
+ return paramEdit === "unchanged" ? withEntry : spliceText(withEntry, paramEdit.start, paramEdit.end, paramEdit.text);
995
+ };
package/workflow/types.ts CHANGED
@@ -47,6 +47,14 @@ export interface AddEnumFieldInput extends PrimitiveTargetInput {
47
47
  includeInLight?: boolean | null;
48
48
  }
49
49
 
50
+ export interface AddMutationInput extends PrimitiveTargetInput {
51
+ mutation: string | null;
52
+ }
53
+
54
+ export interface AddSliceInput extends PrimitiveTargetInput {
55
+ slice: string | null;
56
+ }
57
+
50
58
  export interface WorkflowInputSpec {
51
59
  type: WorkflowInputType;
52
60
  required?: boolean;
@@ -472,4 +480,6 @@ export interface WorkflowPrimitiveOperations {
472
480
  createUi: (input: PrimitiveTargetInput & { surface: UiSurface }) => Promise<PrimitiveWriteReport>;
473
481
  addField: (input: AddFieldInput) => Promise<PrimitiveWriteReport>;
474
482
  addEnumField: (input: AddEnumFieldInput) => Promise<PrimitiveWriteReport>;
483
+ addMutation: (input: AddMutationInput) => Promise<PrimitiveWriteReport>;
484
+ addSlice: (input: AddSliceInput) => Promise<PrimitiveWriteReport>;
475
485
  }