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.
Files changed (43) hide show
  1. package/dist/__tests__/fixtures.d.ts.map +1 -1
  2. package/dist/__tests__/fixtures.js +1 -0
  3. package/dist/__tests__/fixtures.js.map +1 -1
  4. package/dist/cli/commands/add-auth.d.ts.map +1 -1
  5. package/dist/cli/commands/add-auth.js +85 -5
  6. package/dist/cli/commands/add-auth.js.map +1 -1
  7. package/dist/cli/commands/create-model.js +1 -1
  8. package/dist/cli/commands/create-model.js.map +1 -1
  9. package/dist/cli/commands/dev-seeds.js +5 -5
  10. package/dist/cli/commands/dev-seeds.js.map +1 -1
  11. package/dist/cli/commands/dev.js +64 -24
  12. package/dist/cli/commands/dev.js.map +1 -1
  13. package/dist/cli/commands/init.js +4 -4
  14. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  15. package/dist/cli/commands/scaffold.js +61 -5
  16. package/dist/cli/commands/scaffold.js.map +1 -1
  17. package/dist/core/scaffold/auth-generator.d.ts +1 -1
  18. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  19. package/dist/core/scaffold/auth-generator.js +17 -20
  20. package/dist/core/scaffold/auth-generator.js.map +1 -1
  21. package/dist/core/scaffold/functions-generator.d.ts +2 -2
  22. package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
  23. package/dist/core/scaffold/functions-generator.js +375 -107
  24. package/dist/core/scaffold/functions-generator.js.map +1 -1
  25. package/dist/core/scaffold/model-parser.d.ts +7 -0
  26. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  27. package/dist/core/scaffold/model-parser.js +25 -0
  28. package/dist/core/scaffold/model-parser.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +694 -0
  31. package/src/__tests__/auth.test.ts +13 -13
  32. package/src/__tests__/fixtures.ts +1 -0
  33. package/src/__tests__/functions-generator.test.ts +136 -0
  34. package/src/__tests__/model-parser.test.ts +72 -0
  35. package/src/cli/commands/add-auth.ts +95 -6
  36. package/src/cli/commands/create-model.ts +1 -1
  37. package/src/cli/commands/dev-seeds.ts +5 -5
  38. package/src/cli/commands/dev.ts +67 -23
  39. package/src/cli/commands/init.ts +4 -4
  40. package/src/cli/commands/scaffold.ts +69 -10
  41. package/src/core/scaffold/auth-generator.ts +16 -19
  42. package/src/core/scaffold/functions-generator.ts +402 -108
  43. package/src/core/scaffold/model-parser.ts +28 -0
@@ -9,7 +9,7 @@ import {
9
9
  generateBFFAuthLoginRoute,
10
10
  generateBFFAuthLogoutRoute,
11
11
  generateBFFAuthMeRoute,
12
- generateMiddleware,
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("generateMiddleware", () => {
235
+ describe("generateProxy", () => {
236
236
  it("checks for auth cookie", () => {
237
- const code = generateMiddleware("test-project");
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 = generateMiddleware("test-project");
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 = generateMiddleware("test-project");
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 = generateMiddleware("test-project");
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 = generateMiddleware("test-project");
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 = generateMiddleware("test-project");
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 matcher config", () => {
273
- const code = generateMiddleware("test-project");
274
- expect(code).toContain("export const config");
275
- expect(code).toContain("matcher");
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
 
@@ -21,6 +21,7 @@ export function createBasicModelInfo(overrides?: Partial<ModelInfo>): ModelInfo
21
21
  hasCreatedAt: true,
22
22
  hasUpdatedAt: true,
23
23
  nestedSchemaRefs: [],
24
+ partitionKey: '/id',
24
25
  ...overrides,
25
26
  };
26
27
  }
@@ -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
- generateMiddleware,
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 middleware
95
- console.log("\n Generating middleware...");
96
- const middlewarePath = path.join(cwd, "middleware.ts");
97
- fs.writeFileSync(middlewarePath, generateMiddleware(projectName), "utf-8");
98
- console.log(` Created: middleware.ts`);
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: ["/id"],
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: ["/id"],
354
+ paths: [partitionKeyPath],
355
355
  },
356
356
  });
357
357
  }
@@ -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 /id...`);
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: ['/id'],
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: ['/id']
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
- if (fs.existsSync(sharedDir) && fs.existsSync(path.join(sharedDir, 'package.json'))) {
608
- console.log('šŸ“¦ Building shared package...');
609
- const filterArgs = pm === 'pnpm'
610
- ? ['run', '--filter', 'shared', 'build']
611
- : ['run', '--workspace=shared', 'build'];
612
- const sharedBuild = spawn(pm, filterArgs, {
613
- cwd: process.cwd(),
614
- shell: true,
615
- stdio: 'inherit',
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
- await new Promise<void>((resolve, reject) => {
619
- sharedBuild.on('close', (code) => {
620
- if (code === 0) {
621
- console.log('āœ… Shared package built successfully');
622
- resolve();
623
- } else {
624
- reject(new Error(`Shared package build failed with code ${code}`));
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
- sharedBuild.on('error', reject);
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}`,
@@ -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\` | Always \`/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
- - **Do not** change the partition key strategy. All containers use \`/id\` as the partition key.
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 always \`/id\`
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 is always \`/id\`.
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