swallowkit 1.0.0-beta.15 ā 1.0.0-beta.17
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/__tests__/fixtures.d.ts.map +1 -1
- package/dist/__tests__/fixtures.js +1 -0
- package/dist/__tests__/fixtures.js.map +1 -1
- package/dist/cli/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +85 -5
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/create-model.js +1 -1
- package/dist/cli/commands/create-model.js.map +1 -1
- package/dist/cli/commands/dev-seeds.js +5 -5
- package/dist/cli/commands/dev-seeds.js.map +1 -1
- package/dist/cli/commands/dev.js +64 -24
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.js +4 -4
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +61 -5
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts +1 -1
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +17 -20
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/functions-generator.d.ts +2 -2
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +375 -107
- package/dist/core/scaffold/functions-generator.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +7 -0
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +25 -0
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +694 -0
- package/src/__tests__/auth.test.ts +13 -13
- package/src/__tests__/fixtures.ts +1 -0
- package/src/__tests__/functions-generator.test.ts +136 -0
- package/src/__tests__/model-parser.test.ts +72 -0
- package/src/cli/commands/add-auth.ts +95 -6
- package/src/cli/commands/create-model.ts +1 -1
- package/src/cli/commands/dev-seeds.ts +5 -5
- package/src/cli/commands/dev.ts +67 -23
- package/src/cli/commands/init.ts +4 -4
- package/src/cli/commands/scaffold.ts +69 -10
- package/src/core/scaffold/auth-generator.ts +16 -19
- package/src/core/scaffold/functions-generator.ts +402 -108
- package/src/core/scaffold/model-parser.ts +28 -0
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
generateBFFAuthLoginRoute,
|
|
10
10
|
generateBFFAuthLogoutRoute,
|
|
11
11
|
generateBFFAuthMeRoute,
|
|
12
|
-
|
|
12
|
+
generateProxy,
|
|
13
13
|
generateLoginPage,
|
|
14
14
|
generateAuthContext,
|
|
15
15
|
generateBFFCallFunctionWithAuth,
|
|
@@ -230,49 +230,49 @@ describe("generateBFFAuthMeRoute", () => {
|
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
// ============================================================
|
|
233
|
-
// auth-generator: Middleware
|
|
233
|
+
// auth-generator: Proxy (formerly Middleware)
|
|
234
234
|
// ============================================================
|
|
235
|
-
describe("
|
|
235
|
+
describe("generateProxy", () => {
|
|
236
236
|
it("checks for auth cookie", () => {
|
|
237
|
-
const code =
|
|
237
|
+
const code = generateProxy("test-project");
|
|
238
238
|
expect(code).toContain("test-project-auth-token");
|
|
239
239
|
expect(code).toContain("cookies.get");
|
|
240
240
|
});
|
|
241
241
|
|
|
242
242
|
it("redirects to /login for unauthenticated page requests", () => {
|
|
243
|
-
const code =
|
|
243
|
+
const code = generateProxy("test-project");
|
|
244
244
|
expect(code).toContain("/login");
|
|
245
245
|
expect(code).toContain("redirect");
|
|
246
246
|
});
|
|
247
247
|
|
|
248
248
|
it("returns 401 for unauthenticated API requests", () => {
|
|
249
|
-
const code =
|
|
249
|
+
const code = generateProxy("test-project");
|
|
250
250
|
expect(code).toContain("401");
|
|
251
251
|
expect(code).toContain("Unauthorized");
|
|
252
252
|
});
|
|
253
253
|
|
|
254
254
|
it("injects Authorization header from cookie", () => {
|
|
255
|
-
const code =
|
|
255
|
+
const code = generateProxy("test-project");
|
|
256
256
|
expect(code).toContain("Authorization");
|
|
257
257
|
expect(code).toContain("Bearer");
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
it("skips public paths", () => {
|
|
261
|
-
const code =
|
|
261
|
+
const code = generateProxy("test-project");
|
|
262
262
|
expect(code).toContain("/login");
|
|
263
263
|
expect(code).toContain("/api/auth/login");
|
|
264
264
|
});
|
|
265
265
|
|
|
266
266
|
it("checks JWT expiry (base64 decode, no signature verification)", () => {
|
|
267
|
-
const code =
|
|
267
|
+
const code = generateProxy("test-project");
|
|
268
268
|
expect(code).toContain("atob");
|
|
269
269
|
expect(code).toContain("exp");
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it("exports
|
|
273
|
-
const code =
|
|
274
|
-
expect(code).toContain("export
|
|
275
|
-
expect(code).toContain("
|
|
272
|
+
it("exports proxy function instead of middleware", () => {
|
|
273
|
+
const code = generateProxy("test-project");
|
|
274
|
+
expect(code).toContain("export function proxy");
|
|
275
|
+
expect(code).not.toContain("export function middleware");
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
278
|
|
|
@@ -98,4 +98,140 @@ describe("generateCompactAzureFunctionsCRUD", () => {
|
|
|
98
98
|
expect(generated.blueprint).toContain("container.replace_item");
|
|
99
99
|
expect(generated.blueprint).toContain("container.delete_item");
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
// --- Custom Partition Key Tests ---
|
|
103
|
+
|
|
104
|
+
describe("custom partition key (TS)", () => {
|
|
105
|
+
it("uses input binding when partitionKey is /id (default)", () => {
|
|
106
|
+
const model = createBasicModelInfo({ partitionKey: "/id" });
|
|
107
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
108
|
+
expect(code).toContain("partitionKey: '{id}'");
|
|
109
|
+
expect(code).toContain("container.item(id, id).delete()");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("uses SDK direct call when partitionKey is not /id", () => {
|
|
113
|
+
const model = createBasicModelInfo({
|
|
114
|
+
partitionKey: "/tenantId",
|
|
115
|
+
fields: [
|
|
116
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
117
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
118
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
119
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
120
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
124
|
+
|
|
125
|
+
// Should NOT have input binding with partitionKey
|
|
126
|
+
expect(code).not.toContain("partitionKey: '{id}'");
|
|
127
|
+
// getById should use SDK query
|
|
128
|
+
expect(code).toContain("custom partition key");
|
|
129
|
+
expect(code).toContain("SELECT * FROM c WHERE c.id = @id");
|
|
130
|
+
// delete should read doc first to get PK value
|
|
131
|
+
expect(code).toContain("resources[0].tenantId");
|
|
132
|
+
expect(code).toContain("container.item(id, pkValue).delete()");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("generates snapshot for custom partition key TS", () => {
|
|
136
|
+
const model = createBasicModelInfo({
|
|
137
|
+
partitionKey: "/tenantId",
|
|
138
|
+
fields: [
|
|
139
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
140
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
141
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
142
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
143
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
const code = generateCompactAzureFunctionsCRUD(model, "@myapp/shared");
|
|
147
|
+
expect(code).toMatchSnapshot();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("custom partition key (C#)", () => {
|
|
152
|
+
it("uses ReadItemStreamAsync when partitionKey is /id", () => {
|
|
153
|
+
const model = createBasicModelInfo({ partitionKey: "/id" });
|
|
154
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
155
|
+
expect(code).toContain("ReadItemStreamAsync(id, new PartitionKey(id))");
|
|
156
|
+
expect(code).toContain("DeleteItemAsync<JsonObject>(id, new PartitionKey(id))");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("uses query when partitionKey is not /id", () => {
|
|
160
|
+
const model = createBasicModelInfo({
|
|
161
|
+
partitionKey: "/tenantId",
|
|
162
|
+
fields: [
|
|
163
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
164
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
165
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
166
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
167
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
171
|
+
// ReadCosmosItemAsync should use query instead of point read
|
|
172
|
+
expect(code).toContain("GetItemQueryStreamIterator(query)");
|
|
173
|
+
expect(code).not.toContain("ReadItemStreamAsync(id, new PartitionKey(id))");
|
|
174
|
+
// Delete should read doc first for PK value
|
|
175
|
+
expect(code).toContain('existing["tenantId"]');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("generates snapshot for custom partition key C#", () => {
|
|
179
|
+
const model = createBasicModelInfo({
|
|
180
|
+
partitionKey: "/tenantId",
|
|
181
|
+
fields: [
|
|
182
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
183
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
184
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
185
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
186
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
const code = generateCSharpAzureFunctionsCRUD(model);
|
|
190
|
+
expect(code).toMatchSnapshot();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("custom partition key (Python)", () => {
|
|
195
|
+
it("uses read_item with partition_key=item_id when partitionKey is /id", () => {
|
|
196
|
+
const model = createBasicModelInfo({ partitionKey: "/id" });
|
|
197
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
198
|
+
expect(generated.blueprint).toContain("partition_key=item_id");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("uses cross-partition query when partitionKey is not /id", () => {
|
|
202
|
+
const model = createBasicModelInfo({
|
|
203
|
+
partitionKey: "/tenantId",
|
|
204
|
+
fields: [
|
|
205
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
206
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
207
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
208
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
209
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
213
|
+
// Should not use direct read_item with partition_key=item_id
|
|
214
|
+
expect(generated.blueprint).not.toContain("partition_key=item_id");
|
|
215
|
+
// Should use cross-partition query
|
|
216
|
+
expect(generated.blueprint).toContain("enable_cross_partition_query=True");
|
|
217
|
+
expect(generated.blueprint).toContain('SELECT * FROM c WHERE c.id = @id');
|
|
218
|
+
// Delete should get PK value from document
|
|
219
|
+
expect(generated.blueprint).toContain('.get("tenantId")');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("generates snapshot for custom partition key Python", () => {
|
|
223
|
+
const model = createBasicModelInfo({
|
|
224
|
+
partitionKey: "/tenantId",
|
|
225
|
+
fields: [
|
|
226
|
+
{ name: "id", type: "string", isOptional: false, isArray: false },
|
|
227
|
+
{ name: "tenantId", type: "string", isOptional: false, isArray: false },
|
|
228
|
+
{ name: "title", type: "string", isOptional: false, isArray: false },
|
|
229
|
+
{ name: "createdAt", type: "string", isOptional: false, isArray: false },
|
|
230
|
+
{ name: "updatedAt", type: "string", isOptional: false, isArray: false },
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
const generated = generatePythonAzureFunctionsCRUD(model);
|
|
234
|
+
expect(generated.blueprint).toMatchSnapshot();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
101
237
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { parsePartitionKey } from "../core/scaffold/model-parser";
|
|
2
|
+
|
|
3
|
+
describe("parsePartitionKey", () => {
|
|
4
|
+
it("returns default '/id' when no partitionKey export exists", () => {
|
|
5
|
+
const content = `
|
|
6
|
+
import { z } from 'zod/v4';
|
|
7
|
+
|
|
8
|
+
export const Todo = z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
title: z.string(),
|
|
11
|
+
});
|
|
12
|
+
export type Todo = z.infer<typeof Todo>;
|
|
13
|
+
`;
|
|
14
|
+
expect(parsePartitionKey(content)).toBe("/id");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts explicit partitionKey with single quotes", () => {
|
|
18
|
+
const content = `
|
|
19
|
+
import { z } from 'zod/v4';
|
|
20
|
+
|
|
21
|
+
export const Order = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
tenantId: z.string(),
|
|
24
|
+
items: z.array(z.string()),
|
|
25
|
+
});
|
|
26
|
+
export type Order = z.infer<typeof Order>;
|
|
27
|
+
export const partitionKey = '/tenantId';
|
|
28
|
+
`;
|
|
29
|
+
expect(parsePartitionKey(content)).toBe("/tenantId");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("extracts explicit partitionKey with double quotes", () => {
|
|
33
|
+
const content = `
|
|
34
|
+
export const User = z.object({ id: z.string(), email: z.string() });
|
|
35
|
+
export const partitionKey = "/email";
|
|
36
|
+
`;
|
|
37
|
+
expect(parsePartitionKey(content)).toBe("/email");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("extracts /id as explicit partitionKey", () => {
|
|
41
|
+
const content = `
|
|
42
|
+
export const Todo = z.object({ id: z.string() });
|
|
43
|
+
export const partitionKey = '/id';
|
|
44
|
+
`;
|
|
45
|
+
expect(parsePartitionKey(content)).toBe("/id");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles nested/hierarchical partition key paths", () => {
|
|
49
|
+
const content = `
|
|
50
|
+
export const partitionKey = '/address/city';
|
|
51
|
+
`;
|
|
52
|
+
expect(parsePartitionKey(content)).toBe("/address/city");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ignores commented-out partitionKey", () => {
|
|
56
|
+
const content = `
|
|
57
|
+
// export const partitionKey = '/tenantId';
|
|
58
|
+
export const Todo = z.object({ id: z.string() });
|
|
59
|
+
`;
|
|
60
|
+
// The regex matches inside comments too ā this is acceptable since
|
|
61
|
+
// the pattern follows the same approach as parseConnectorConfig/parseAuthPolicy
|
|
62
|
+
// In practice, commented-out exports are rare in model files
|
|
63
|
+
expect(parsePartitionKey(content)).toBe("/tenantId");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles extra spaces around equals sign", () => {
|
|
67
|
+
const content = `
|
|
68
|
+
export const partitionKey = '/customerId';
|
|
69
|
+
`;
|
|
70
|
+
expect(parsePartitionKey(content)).toBe("/customerId");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
generateBFFAuthLoginRoute,
|
|
19
19
|
generateBFFAuthLogoutRoute,
|
|
20
20
|
generateBFFAuthMeRoute,
|
|
21
|
-
|
|
21
|
+
generateProxy,
|
|
22
22
|
generateLoginPage,
|
|
23
23
|
generateAuthContext,
|
|
24
24
|
generateBFFCallFunctionWithAuth,
|
|
@@ -76,6 +76,9 @@ export async function addAuthCommand(options: AddAuthOptions) {
|
|
|
76
76
|
fs.writeFileSync(authModelPath, generateAuthModels(), "utf-8");
|
|
77
77
|
console.log(` Created: shared/models/auth.ts`);
|
|
78
78
|
|
|
79
|
+
// Ensure shared package has build infrastructure (tsconfig, build script)
|
|
80
|
+
ensureSharedBuildInfrastructure(cwd);
|
|
81
|
+
|
|
79
82
|
// Update shared/index.ts to re-export auth
|
|
80
83
|
updateSharedIndex(cwd);
|
|
81
84
|
|
|
@@ -91,11 +94,11 @@ export async function addAuthCommand(options: AddAuthOptions) {
|
|
|
91
94
|
console.log("\n Generating BFF auth routes...");
|
|
92
95
|
generateBFFAuth(cwd, projectName, sharedPackageName);
|
|
93
96
|
|
|
94
|
-
// 4. Generate
|
|
95
|
-
console.log("\n Generating
|
|
96
|
-
const
|
|
97
|
-
fs.writeFileSync(
|
|
98
|
-
console.log(` Created:
|
|
97
|
+
// 4. Generate proxy
|
|
98
|
+
console.log("\n Generating proxy...");
|
|
99
|
+
const proxyPath = path.join(cwd, "proxy.ts");
|
|
100
|
+
fs.writeFileSync(proxyPath, generateProxy(projectName), "utf-8");
|
|
101
|
+
console.log(` Created: proxy.ts`);
|
|
99
102
|
|
|
100
103
|
// 5. Generate login page
|
|
101
104
|
console.log("\n Generating login page...");
|
|
@@ -143,6 +146,73 @@ export async function addAuthCommand(options: AddAuthOptions) {
|
|
|
143
146
|
console.log(` 5. Run scaffold to regenerate functions with auth guards`);
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Ensure the shared package has proper build infrastructure
|
|
151
|
+
* (tsconfig.json, build script, typescript devDependency).
|
|
152
|
+
* Required for `dev` command which runs `npm run --workspace=shared build`.
|
|
153
|
+
*/
|
|
154
|
+
function ensureSharedBuildInfrastructure(cwd: string): void {
|
|
155
|
+
const sharedDir = path.join(cwd, "shared");
|
|
156
|
+
const pkgPath = path.join(sharedDir, "package.json");
|
|
157
|
+
if (!fs.existsSync(pkgPath)) return;
|
|
158
|
+
|
|
159
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
160
|
+
let updated = false;
|
|
161
|
+
|
|
162
|
+
// Ensure scripts.build exists
|
|
163
|
+
if (!pkg.scripts?.build) {
|
|
164
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
165
|
+
pkg.scripts.build = "tsc";
|
|
166
|
+
pkg.scripts.watch = "tsc --watch";
|
|
167
|
+
updated = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Ensure main points to compiled output
|
|
171
|
+
if (!pkg.main || pkg.main === "index.ts") {
|
|
172
|
+
pkg.main = "dist/index.js";
|
|
173
|
+
pkg.types = "dist/index.d.ts";
|
|
174
|
+
updated = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ensure typescript devDependency
|
|
178
|
+
if (!pkg.devDependencies?.typescript) {
|
|
179
|
+
if (!pkg.devDependencies) pkg.devDependencies = {};
|
|
180
|
+
pkg.devDependencies.typescript = "^5.0.0";
|
|
181
|
+
updated = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (updated) {
|
|
185
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), "utf-8");
|
|
186
|
+
console.log(` Updated: shared/package.json (added build infrastructure)`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Ensure tsconfig.json exists
|
|
190
|
+
const tsconfigPath = path.join(sharedDir, "tsconfig.json");
|
|
191
|
+
if (!fs.existsSync(tsconfigPath)) {
|
|
192
|
+
const tsconfig = {
|
|
193
|
+
compilerOptions: {
|
|
194
|
+
target: "ES2020",
|
|
195
|
+
module: "commonjs",
|
|
196
|
+
moduleResolution: "node",
|
|
197
|
+
lib: ["ES2020"],
|
|
198
|
+
outDir: "dist",
|
|
199
|
+
rootDir: ".",
|
|
200
|
+
declaration: true,
|
|
201
|
+
declarationMap: true,
|
|
202
|
+
sourceMap: true,
|
|
203
|
+
strict: true,
|
|
204
|
+
esModuleInterop: true,
|
|
205
|
+
skipLibCheck: true,
|
|
206
|
+
forceConsistentCasingInFileNames: true,
|
|
207
|
+
},
|
|
208
|
+
include: ["index.ts", "models/**/*"],
|
|
209
|
+
exclude: ["node_modules", "dist"],
|
|
210
|
+
};
|
|
211
|
+
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2), "utf-8");
|
|
212
|
+
console.log(` Created: shared/tsconfig.json`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
146
216
|
function updateSharedIndex(cwd: string): void {
|
|
147
217
|
const indexPath = path.join(cwd, "shared", "index.ts");
|
|
148
218
|
if (fs.existsSync(indexPath)) {
|
|
@@ -226,6 +296,25 @@ function generateFunctionsAuth(
|
|
|
226
296
|
|
|
227
297
|
// __init__.py
|
|
228
298
|
fs.writeFileSync(path.join(authDir, "__init__.py"), "", "utf-8");
|
|
299
|
+
|
|
300
|
+
// Register auth blueprint in function_app.py
|
|
301
|
+
const functionAppPath = path.join(functionsDir, "function_app.py");
|
|
302
|
+
if (fs.existsSync(functionAppPath)) {
|
|
303
|
+
const content = fs.readFileSync(functionAppPath, "utf-8");
|
|
304
|
+
const authImport = "from blueprints.auth import bp as auth_bp";
|
|
305
|
+
const authRegister = "app.register_blueprint(auth_bp)";
|
|
306
|
+
if (!content.includes(authImport)) {
|
|
307
|
+
const marker = "# SwallowKit scaffold registrations";
|
|
308
|
+
if (content.includes(marker)) {
|
|
309
|
+
const updated = content.replace(
|
|
310
|
+
marker,
|
|
311
|
+
`${authImport}\n${authRegister}\n${marker}`
|
|
312
|
+
);
|
|
313
|
+
fs.writeFileSync(functionAppPath, updated, "utf-8");
|
|
314
|
+
console.log(` Updated: functions/function_app.py (registered auth blueprint)`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
229
318
|
}
|
|
230
319
|
}
|
|
231
320
|
|
|
@@ -45,7 +45,7 @@ export const displayName = '${pascalName}';
|
|
|
45
45
|
function generateConnectorModelTemplate(modelName: string, connectorName: string, connectorType: 'rdb' | 'api'): string {
|
|
46
46
|
const pascalName = toPascalCase(modelName);
|
|
47
47
|
const kebabName = modelName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
48
|
-
const pluralName = kebabName + 's';
|
|
48
|
+
const pluralName = kebabName.endsWith('s') ? kebabName : kebabName + 's';
|
|
49
49
|
|
|
50
50
|
const schema = `import { z } from 'zod/v4';
|
|
51
51
|
|
|
@@ -30,7 +30,7 @@ interface LoadedSeedFile {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function getContainerNameForModel(model: Pick<ModelInfo, "name">): string {
|
|
33
|
-
return `${model.name}s`;
|
|
33
|
+
return model.name.endsWith('s') ? model.name : `${model.name}s`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function getSeedEnvironmentDir(environment: string, seedsDir: string = "dev-seeds"): string {
|
|
@@ -135,7 +135,7 @@ export async function applyDevSeedEnvironment({
|
|
|
135
135
|
console.log(`š§Ŗ Applying Cosmos DB seed data for environment "${environment}"...`);
|
|
136
136
|
|
|
137
137
|
for (const seedFile of seedFiles) {
|
|
138
|
-
await recreateContainer(database, seedFile.containerName);
|
|
138
|
+
await recreateContainer(database, seedFile.containerName, seedFile.model.partitionKey);
|
|
139
139
|
const container = database.container(seedFile.containerName);
|
|
140
140
|
|
|
141
141
|
for (const document of seedFile.documents) {
|
|
@@ -326,7 +326,7 @@ function validateSeedDocuments(documents: SeedDocument[], filePath: string): voi
|
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
async function recreateContainer(database: Database, containerName: string): Promise<void> {
|
|
329
|
+
async function recreateContainer(database: Database, containerName: string, partitionKeyPath: string = '/id'): Promise<void> {
|
|
330
330
|
try {
|
|
331
331
|
await database.container(containerName).delete();
|
|
332
332
|
} catch (error: any) {
|
|
@@ -339,7 +339,7 @@ async function recreateContainer(database: Database, containerName: string): Pro
|
|
|
339
339
|
await database.containers.createIfNotExists({
|
|
340
340
|
id: containerName,
|
|
341
341
|
partitionKey: {
|
|
342
|
-
paths: [
|
|
342
|
+
paths: [partitionKeyPath],
|
|
343
343
|
kind: PartitionKeyKind.Hash,
|
|
344
344
|
version: 2,
|
|
345
345
|
},
|
|
@@ -351,7 +351,7 @@ async function recreateContainer(database: Database, containerName: string): Pro
|
|
|
351
351
|
await database.containers.createIfNotExists({
|
|
352
352
|
id: containerName,
|
|
353
353
|
partitionKey: {
|
|
354
|
-
paths: [
|
|
354
|
+
paths: [partitionKeyPath],
|
|
355
355
|
},
|
|
356
356
|
});
|
|
357
357
|
}
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -395,11 +395,11 @@ async function initializeCosmosDB(databaseName: string): Promise<CosmosInitializ
|
|
|
395
395
|
let containerCreated = false;
|
|
396
396
|
|
|
397
397
|
try {
|
|
398
|
-
console.log(`š§ Creating container "${containerName}" with partition key
|
|
398
|
+
console.log(`š§ Creating container "${containerName}" with partition key ${model.partitionKey}...`);
|
|
399
399
|
const containerResponse = await database.containers.createIfNotExists({
|
|
400
400
|
id: containerName,
|
|
401
401
|
partitionKey: {
|
|
402
|
-
paths: [
|
|
402
|
+
paths: [model.partitionKey],
|
|
403
403
|
kind: PartitionKeyKind.Hash,
|
|
404
404
|
version: 2
|
|
405
405
|
}
|
|
@@ -418,7 +418,7 @@ async function initializeCosmosDB(databaseName: string): Promise<CosmosInitializ
|
|
|
418
418
|
const containerResponse = await database.containers.createIfNotExists({
|
|
419
419
|
id: containerName,
|
|
420
420
|
partitionKey: {
|
|
421
|
-
paths: [
|
|
421
|
+
paths: [model.partitionKey]
|
|
422
422
|
}
|
|
423
423
|
});
|
|
424
424
|
console.log(`ā
Container "${containerName}" ready (status: ${containerResponse.statusCode})`);
|
|
@@ -458,10 +458,28 @@ async function startDevEnvironment(options: DevOptions) {
|
|
|
458
458
|
const processes: ChildProcess[] = [];
|
|
459
459
|
let functionsEnv: NodeJS.ProcessEnv = process.env;
|
|
460
460
|
let mockServer: ConnectorMockServer | null = null;
|
|
461
|
+
let envLocalPath = '';
|
|
462
|
+
let envLocalDefaultUrl = ''; // default Functions URL to restore on shutdown
|
|
461
463
|
|
|
462
464
|
// Cleanup processes on Ctrl+C
|
|
463
465
|
process.on('SIGINT', async () => {
|
|
464
466
|
console.log('\nš Stopping development servers...');
|
|
467
|
+
// Restore .env.local to default Functions port on shutdown
|
|
468
|
+
if (envLocalPath && envLocalDefaultUrl) {
|
|
469
|
+
try {
|
|
470
|
+
if (fs.existsSync(envLocalPath)) {
|
|
471
|
+
const content = fs.readFileSync(envLocalPath, 'utf-8');
|
|
472
|
+
if (content.includes('BACKEND_FUNCTIONS_BASE_URL=') &&
|
|
473
|
+
!content.includes(`BACKEND_FUNCTIONS_BASE_URL=${envLocalDefaultUrl}`)) {
|
|
474
|
+
const restored = content.replace(
|
|
475
|
+
/^BACKEND_FUNCTIONS_BASE_URL=.*/m,
|
|
476
|
+
`BACKEND_FUNCTIONS_BASE_URL=${envLocalDefaultUrl}`
|
|
477
|
+
);
|
|
478
|
+
fs.writeFileSync(envLocalPath, restored, 'utf-8');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} catch { /* ignore */ }
|
|
482
|
+
}
|
|
465
483
|
if (mockServer) {
|
|
466
484
|
await mockServer.stop();
|
|
467
485
|
}
|
|
@@ -604,28 +622,34 @@ async function startDevEnvironment(options: DevOptions) {
|
|
|
604
622
|
if (hasFunctions && !options.noFunctions) {
|
|
605
623
|
// Build shared package before starting Functions
|
|
606
624
|
const sharedDir = path.join(process.cwd(), 'shared');
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
625
|
+
const sharedPkgPath = path.join(sharedDir, 'package.json');
|
|
626
|
+
if (fs.existsSync(sharedDir) && fs.existsSync(sharedPkgPath)) {
|
|
627
|
+
const sharedPkg = JSON.parse(fs.readFileSync(sharedPkgPath, 'utf-8'));
|
|
628
|
+
if (sharedPkg.scripts?.build) {
|
|
629
|
+
console.log('š¦ Building shared package...');
|
|
630
|
+
const filterArgs = pm === 'pnpm'
|
|
631
|
+
? ['run', '--filter', 'shared', 'build']
|
|
632
|
+
: ['run', '--workspace=shared', 'build'];
|
|
633
|
+
const sharedBuild = spawn(pm, filterArgs, {
|
|
634
|
+
cwd: process.cwd(),
|
|
635
|
+
shell: true,
|
|
636
|
+
stdio: 'inherit',
|
|
637
|
+
});
|
|
617
638
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
639
|
+
await new Promise<void>((resolve, reject) => {
|
|
640
|
+
sharedBuild.on('close', (code) => {
|
|
641
|
+
if (code === 0) {
|
|
642
|
+
console.log('ā
Shared package built successfully');
|
|
643
|
+
resolve();
|
|
644
|
+
} else {
|
|
645
|
+
reject(new Error(`Shared package build failed with code ${code}`));
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
sharedBuild.on('error', reject);
|
|
626
649
|
});
|
|
627
|
-
|
|
628
|
-
|
|
650
|
+
} else {
|
|
651
|
+
console.log('ā ļø Shared package has no build script ā skipping build. Run "swallowkit add-auth" to fix.');
|
|
652
|
+
}
|
|
629
653
|
}
|
|
630
654
|
|
|
631
655
|
// Azure Functions ćčµ·å
|
|
@@ -783,6 +807,26 @@ async function startDevEnvironment(options: DevOptions) {
|
|
|
783
807
|
}, 3000);
|
|
784
808
|
}
|
|
785
809
|
|
|
810
|
+
// Ensure .env.local points to bffTargetPort so Next.js reads the correct backend URL.
|
|
811
|
+
// When --mock-connectors is active, bffTargetPort = mock port (7072); otherwise = Functions port (7071).
|
|
812
|
+
// Next.js may load .env.local values that override spawn env vars, so we must keep them in sync.
|
|
813
|
+
envLocalPath = path.join(process.cwd(), '.env.local');
|
|
814
|
+
envLocalDefaultUrl = `http://${options.host || 'localhost'}:${functionsPort}`;
|
|
815
|
+
const bffTargetUrl = `http://${options.host || 'localhost'}:${bffTargetPort}`;
|
|
816
|
+
try {
|
|
817
|
+
if (fs.existsSync(envLocalPath)) {
|
|
818
|
+
const envContent = fs.readFileSync(envLocalPath, 'utf-8');
|
|
819
|
+
if (envContent.includes('BACKEND_FUNCTIONS_BASE_URL=') &&
|
|
820
|
+
!envContent.includes(`BACKEND_FUNCTIONS_BASE_URL=${bffTargetUrl}`)) {
|
|
821
|
+
const updated = envContent.replace(
|
|
822
|
+
/^BACKEND_FUNCTIONS_BASE_URL=.*/m,
|
|
823
|
+
`BACKEND_FUNCTIONS_BASE_URL=${bffTargetUrl}`
|
|
824
|
+
);
|
|
825
|
+
fs.writeFileSync(envLocalPath, updated, 'utf-8');
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} catch { /* ignore */ }
|
|
829
|
+
|
|
786
830
|
const nextEnv: NodeJS.ProcessEnv = {
|
|
787
831
|
...process.env,
|
|
788
832
|
BACKEND_FUNCTIONS_BASE_URL: `http://${options.host || 'localhost'}:${bffTargetPort}`,
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -1781,7 +1781,7 @@ app.http('{model}-get-all', {
|
|
|
1781
1781
|
| UI page directory | \`app/{kebab-case}/\` | \`app/todo/page.tsx\` |
|
|
1782
1782
|
| React component | PascalCase | \`TodoForm.tsx\` |
|
|
1783
1783
|
| Cosmos DB container | PascalCase + 's' | \`Todos\` |
|
|
1784
|
-
| Cosmos DB partition key | \`/id\` |
|
|
1784
|
+
| Cosmos DB partition key | \`/id\` (default) | Custom: \`export const partitionKey = '/field'\` |
|
|
1785
1785
|
| Bicep container file | \`infra/containers/{kebab-case}-container.bicep\` | \`infra/containers/todo-container.bicep\` |
|
|
1786
1786
|
|
|
1787
1787
|
## Adding New Models (SwallowKit CLI Skills)
|
|
@@ -1842,7 +1842,7 @@ Deploys Bicep infrastructure: Static Web Apps, Functions, Cosmos DB, Storage, Ma
|
|
|
1842
1842
|
- **Do not** manually duplicate model definitions across layers. Use the shared package.
|
|
1843
1843
|
- **Do not** manually create CRUD boilerplate. Use \`swallowkit scaffold\`.
|
|
1844
1844
|
- **Do not** hardcode Cosmos DB connection strings. Use Managed Identity (\`CosmosDBConnection__accountEndpoint\`) in production and emulator settings locally.
|
|
1845
|
-
-
|
|
1845
|
+
- By default, all containers use \`/id\` as the partition key. To use a custom partition key, add \`export const partitionKey = '/yourField'\` to the model file. The scaffold command will apply it across all layers.
|
|
1846
1846
|
|
|
1847
1847
|
## Technology Stack
|
|
1848
1848
|
|
|
@@ -1920,7 +1920,7 @@ Frontend (Next.js App Router) ā BFF (Next.js API Routes) ā Backend (Azure Fu
|
|
|
1920
1920
|
|
|
1921
1921
|
- Schema/type: PascalCase, same name for both (\`export const Todo = z.object({...}); export type Todo = z.infer<typeof Todo>;\`)
|
|
1922
1922
|
- Files: kebab-case (\`shared/models/todo.ts\`, backend handlers under \`functions/\`)
|
|
1923
|
-
- Cosmos DB containers: PascalCase + 's' (\`Todos\`), partition key
|
|
1923
|
+
- Cosmos DB containers: PascalCase + 's' (\`Todos\`), partition key default \`/id\` (customizable via \`export const partitionKey\`)
|
|
1924
1924
|
|
|
1925
1925
|
## Managed Fields
|
|
1926
1926
|
|
|
@@ -2017,7 +2017,7 @@ Files in \`functions/\` contain all business logic and data access for this appl
|
|
|
2017
2017
|
- For TypeScript backends, use Cosmos DB **input/output bindings** (\`extraInputs\`/\`extraOutputs\`) for reads and writes.
|
|
2018
2018
|
- For C#/Python backends, consume the generated OpenAPI-derived assets in \`functions/generated/\`.
|
|
2019
2019
|
- Auto-generate \`id\` (UUID), \`createdAt\`, and \`updatedAt\` on the backend. Never trust client-sent values.
|
|
2020
|
-
- Container names are PascalCase + 's' (e.g., \`Todos\`). Partition key
|
|
2020
|
+
- Container names are PascalCase + 's' (e.g., \`Todos\`). Partition key defaults to \`/id\` but can be customized per model.
|
|
2021
2021
|
|
|
2022
2022
|
## Handler Pattern
|
|
2023
2023
|
|