swallowkit 1.0.0-beta.16 โ†’ 1.0.0-beta.18

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.
@@ -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
+ });
@@ -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})`);
@@ -652,6 +652,18 @@ async function startDevEnvironment(options: DevOptions) {
652
652
  }
653
653
  }
654
654
 
655
+ // Build TypeScript functions after shared package (functions import from shared)
656
+ if (backendLanguage === 'typescript') {
657
+ const functionsPkgPath = path.join(functionsDir, 'package.json');
658
+ if (fs.existsSync(functionsPkgPath)) {
659
+ const functionsPkg = JSON.parse(fs.readFileSync(functionsPkgPath, 'utf-8'));
660
+ if (functionsPkg.scripts?.build) {
661
+ console.log('๐Ÿ“ฆ Building TypeScript Azure Functions...');
662
+ await runCommand(pm, ['run', 'build'], functionsDir, `${pm} run build`);
663
+ }
664
+ }
665
+ }
666
+
655
667
  // Azure Functions ใ‚’่ตทๅ‹•
656
668
  const funcProcess = spawn('func', buildFunctionsStartArgs(functionsPort), {
657
669
  cwd: functionsDir,
@@ -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
 
@@ -947,7 +947,7 @@ param databaseName string
947
947
  param containerName string = '${modelPascal}s'
948
948
 
949
949
  @description('Partition key path')
950
- param partitionKeyPath string = '/id'
950
+ param partitionKeyPath string = '${modelInfo.partitionKey}'
951
951
 
952
952
  @description('Throughput (RU/s) - only used for Free Tier')
953
953
  param throughput int = 400