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.
- 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/dev-seeds.js +4 -4
- package/dist/cli/commands/dev-seeds.js.map +1 -1
- package/dist/cli/commands/dev.js +14 -3
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.js +4 -4
- package/dist/cli/commands/scaffold.js +1 -1
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +341 -85
- 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 +23 -0
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +694 -0
- 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/dev-seeds.ts +4 -4
- package/src/cli/commands/dev.ts +15 -3
- package/src/cli/commands/init.ts +4 -4
- package/src/cli/commands/scaffold.ts +1 -1
- package/src/core/scaffold/functions-generator.ts +365 -85
- package/src/core/scaffold/model-parser.ts +26 -0
|
@@ -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: [
|
|
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})`);
|
|
@@ -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,
|
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
|
|
|
@@ -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 = '
|
|
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
|