instant-cli 1.0.39 → 1.0.40-branch-python-sdk-v1.26482536960.1

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.
@@ -0,0 +1,472 @@
1
+ import { FileSystem, Path } from '@effect/platform';
2
+ import chalk from 'chalk';
3
+ import { Effect, Schema } from 'effect';
4
+ import { readLocalSchemaFile } from '../old.js';
5
+ import {
6
+ ReadSchemaFileError,
7
+ SchemaValidationError,
8
+ } from '../lib/pushSchema.ts';
9
+
10
+ export class GenpyWriteError extends Schema.TaggedError<GenpyWriteError>(
11
+ 'GenpyWriteError',
12
+ )('GenpyWriteError', {
13
+ message: Schema.String,
14
+ }) {}
15
+
16
+ const PY_HEADER = `# AUTOGENERATED by \`instant-cli genpy\`. DO NOT EDIT THIS FILE BY HAND.
17
+ # Re-run \`npx instant-cli genpy\` after schema changes.
18
+ `;
19
+
20
+ export const genpyCommand = ({ outDir }: { outDir?: string }) =>
21
+ Effect.gen(function* () {
22
+ const localSchemaFile = yield* Effect.tryPromise({
23
+ try: readLocalSchemaFile,
24
+ catch: (e) =>
25
+ e instanceof Error
26
+ ? ReadSchemaFileError.make({ message: e.message })
27
+ : ReadSchemaFileError.make({ message: String(e) }),
28
+ });
29
+ if (!localSchemaFile || !localSchemaFile.schema) {
30
+ return yield* ReadSchemaFileError.make({
31
+ message: `We couldn't find your ${chalk.yellow('`instant.schema.ts`')} file. Make sure it's in the project root or under \`src/\`. (Hint: set INSTANT_SCHEMA_FILE_PATH to override.)`,
32
+ });
33
+ }
34
+ if (localSchemaFile.schema.constructor?.name !== 'InstantSchemaDef') {
35
+ return yield* SchemaValidationError.make({
36
+ message: `We couldn't find your schema export.\nIn your ${chalk.yellow('`instant.schema.ts`')} file, make sure you ${chalk.green('`export default schema`')}`,
37
+ });
38
+ }
39
+
40
+ const path = yield* Path.Path;
41
+ const fs = yield* FileSystem.FileSystem;
42
+ // Default: write alongside the schema file. `--out-dir` overrides for
43
+ // monorepos where Python lives elsewhere, or for our own sandbox where
44
+ // the schema is shared with the JS CLI tests.
45
+ const targetDir = outDir ? path.resolve(outDir) : path.dirname(localSchemaFile.path);
46
+ yield* fs.makeDirectory(targetDir, { recursive: true }).pipe(
47
+ Effect.mapError((e) =>
48
+ GenpyWriteError.make({ message: `Failed to create ${targetDir}: ${e}` }),
49
+ ),
50
+ );
51
+ const pyPath = path.join(targetDir, 'instant_types.py');
52
+ const pyiPath = path.join(targetDir, 'instant_types.pyi');
53
+
54
+ const pyContent = buildEntityModelsPy(localSchemaFile.schema);
55
+ const pyiContent = buildTxStubPyi(localSchemaFile.schema);
56
+ yield* fs.writeFileString(pyPath, pyContent).pipe(
57
+ Effect.mapError((e) =>
58
+ GenpyWriteError.make({ message: `Failed to write ${pyPath}: ${e}` }),
59
+ ),
60
+ );
61
+ yield* fs.writeFileString(pyiPath, pyiContent).pipe(
62
+ Effect.mapError((e) =>
63
+ GenpyWriteError.make({ message: `Failed to write ${pyiPath}: ${e}` }),
64
+ ),
65
+ );
66
+
67
+ yield* Effect.log(`✅ Wrote ${pyPath} and ${pyiPath}`);
68
+ });
69
+
70
+ // ---------- entity model codegen ----------
71
+
72
+ type SchemaLike = {
73
+ entities: Record<string, EntityDefLike>;
74
+ links: Record<string, LinkDefLike>;
75
+ };
76
+
77
+ type EntityDefLike = {
78
+ attrs: Record<string, DataAttrDefLike>;
79
+ };
80
+
81
+ type DataAttrDefLike = {
82
+ valueType: 'string' | 'number' | 'boolean' | 'date' | 'json';
83
+ required: boolean;
84
+ };
85
+
86
+ type LinkDefLike = {
87
+ forward: { on: string; label: string; has: 'one' | 'many' };
88
+ reverse: { on: string; label: string; has: 'one' | 'many' };
89
+ };
90
+
91
+ type Field = { name: string; type: string; hasDefault: boolean };
92
+
93
+ function className(entName: string): string {
94
+ // Strip a leading `$` (system entities like `$users`, `$files`), then
95
+ // PascalCase across word separators (`-`, `_`, whitespace) so non-identifier
96
+ // characters in entity names don't produce invalid Python class names.
97
+ const stripped = entName.startsWith('$') ? entName.slice(1) : entName;
98
+ return stripped
99
+ .split(/[-_\s]+/)
100
+ .filter(Boolean)
101
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
102
+ .join('');
103
+ }
104
+
105
+ function mapValueType(vt: DataAttrDefLike['valueType']): string {
106
+ switch (vt) {
107
+ case 'string':
108
+ return 'str';
109
+ case 'number':
110
+ return 'float';
111
+ case 'boolean':
112
+ return 'bool';
113
+ case 'date':
114
+ return 'datetime';
115
+ case 'json':
116
+ return 'Any';
117
+ default:
118
+ // Defensive: if a new valueType ever lands in the schema without a
119
+ // genpy update, fall back to Any so generated code stays valid Python.
120
+ return 'Any';
121
+ }
122
+ }
123
+
124
+ export function buildEntityModelsPy(schema: SchemaLike): string {
125
+ const entityNames = Object.keys(schema.entities).sort();
126
+ const fieldsByEntity: Record<string, Field[]> = {};
127
+ const linkFieldsByEntity: Record<string, Field[]> = {};
128
+ let usesDatetime = false;
129
+
130
+ for (const entName of entityNames) {
131
+ fieldsByEntity[entName] = [{ name: 'id', type: 'str', hasDefault: false }];
132
+ linkFieldsByEntity[entName] = [];
133
+ for (const [attrName, attrDef] of Object.entries(
134
+ schema.entities[entName].attrs,
135
+ )) {
136
+ const baseType = mapValueType(attrDef.valueType);
137
+ if (baseType === 'datetime') usesDatetime = true;
138
+ const type = attrDef.required ? baseType : `${baseType} | None`;
139
+ fieldsByEntity[entName].push({
140
+ name: attrName,
141
+ type,
142
+ hasDefault: !attrDef.required,
143
+ });
144
+ }
145
+ }
146
+
147
+ for (const link of Object.values(schema.links)) {
148
+ const fwd = link.forward;
149
+ const rev = link.reverse;
150
+ if (linkFieldsByEntity[fwd.on]) {
151
+ linkFieldsByEntity[fwd.on].push({
152
+ name: fwd.label,
153
+ type:
154
+ fwd.has === 'one'
155
+ ? `${className(rev.on)} | None`
156
+ : `list[${className(rev.on)}] | None`,
157
+ hasDefault: true,
158
+ });
159
+ }
160
+ if (linkFieldsByEntity[rev.on]) {
161
+ linkFieldsByEntity[rev.on].push({
162
+ name: rev.label,
163
+ type:
164
+ rev.has === 'one'
165
+ ? `${className(fwd.on)} | None`
166
+ : `list[${className(fwd.on)}] | None`,
167
+ hasDefault: true,
168
+ });
169
+ }
170
+ }
171
+
172
+ // Sort link fields alphabetically for stable output across schema rewrites.
173
+ for (const entName of entityNames) {
174
+ linkFieldsByEntity[entName].sort((a, b) => a.name.localeCompare(b.name));
175
+ }
176
+
177
+ const classBlocks = entityNames.map((entName) => {
178
+ const lines = [
179
+ `class ${className(entName)}(BaseModel):`,
180
+ ` model_config = ConfigDict(extra="ignore")`,
181
+ ];
182
+ for (const f of [
183
+ ...fieldsByEntity[entName],
184
+ ...linkFieldsByEntity[entName],
185
+ ]) {
186
+ const decl = f.hasDefault
187
+ ? ` ${f.name}: ${f.type} = None`
188
+ : ` ${f.name}: ${f.type}`;
189
+ lines.push(decl);
190
+ }
191
+ return lines.join('\n');
192
+ });
193
+
194
+ const rebuilds = entityNames.map(
195
+ (entName) => `${className(entName)}.model_rebuild()`,
196
+ );
197
+
198
+ // Webhook record / handler types live in the same .py so users get one
199
+ // import point (`from instant_types import ...`) for both query results
200
+ // and webhook handler type-checking.
201
+ const webhookSection = buildWebhookSection(schema, entityNames);
202
+
203
+ const typingImports = ['Any', 'Callable', 'Literal', 'TypedDict'];
204
+
205
+ const stdlibImports: string[] = [];
206
+ if (usesDatetime) stdlibImports.push('from datetime import datetime');
207
+ stdlibImports.push(`from typing import ${typingImports.join(', ')}`);
208
+ const importBlock =
209
+ stdlibImports.join('\n') + '\n\n' +
210
+ 'from pydantic import BaseModel, ConfigDict';
211
+
212
+ // Add record-model rebuilds so forward-string annotations to entity
213
+ // types resolve under `from __future__ import annotations`.
214
+ const recordRebuilds: string[] = [];
215
+ for (const entName of entityNames) {
216
+ const cls = className(entName);
217
+ for (const action of ['Create', 'Update', 'Delete']) {
218
+ recordRebuilds.push(`${cls}${action}Record.model_rebuild()`);
219
+ }
220
+ }
221
+
222
+ // Registry consumed by `Instant(schema=schema)`.
223
+ const entityMapLines = [' "entities": {'];
224
+ for (const entName of entityNames) {
225
+ entityMapLines.push(` ${JSON.stringify(entName)}: ${className(entName)},`);
226
+ }
227
+ entityMapLines.push(' },');
228
+ const recordMapLines = [' "records": {'];
229
+ for (const entName of entityNames) {
230
+ const cls = className(entName);
231
+ for (const action of ['create', 'update', 'delete']) {
232
+ const capAction = action.charAt(0).toUpperCase() + action.slice(1);
233
+ recordMapLines.push(
234
+ ` (${JSON.stringify(entName)}, ${JSON.stringify(action)}): ${cls}${capAction}Record,`,
235
+ );
236
+ }
237
+ }
238
+ recordMapLines.push(' },');
239
+ const schemaLiteral = [
240
+ 'schema = {',
241
+ ...entityMapLines,
242
+ ...recordMapLines,
243
+ '}',
244
+ ].join('\n');
245
+
246
+ return [
247
+ PY_HEADER + '"""Generated Pydantic models for Instant schema."""',
248
+ '',
249
+ 'from __future__ import annotations',
250
+ '',
251
+ importBlock,
252
+ '',
253
+ '',
254
+ classBlocks.join('\n\n\n'),
255
+ '',
256
+ '',
257
+ '# Resolve forward references introduced by `from __future__ import annotations`.',
258
+ rebuilds.join('\n'),
259
+ '',
260
+ '',
261
+ webhookSection,
262
+ '',
263
+ '',
264
+ '# Rebuild webhook records so `before` / `after` annotations resolve to entity classes.',
265
+ recordRebuilds.join('\n'),
266
+ '',
267
+ '',
268
+ '# Registry consumed by `Instant(schema=schema)` for query-result + webhook validation.',
269
+ schemaLiteral,
270
+ '',
271
+ ].join('\n');
272
+ }
273
+
274
+ // ---------- webhook record / handler codegen ----------
275
+
276
+ function buildWebhookSection(
277
+ schema: SchemaLike,
278
+ entityNames: string[],
279
+ ): string {
280
+ // Schemas with zero entities would produce `WebhookRecord = ` (no RHS).
281
+ if (entityNames.length === 0) return '';
282
+
283
+ const recordClasses: string[] = [];
284
+ const namespaceUnions: string[] = [];
285
+ const namespaceHandlerDicts: string[] = [];
286
+
287
+ // `before`/`after` shape varies by action.
288
+ const RECORD_VARIANTS = [
289
+ { action: 'create', before: 'None = None', afterTpl: (c: string) => c },
290
+ { action: 'update', before: 'CLS', afterTpl: (c: string) => c },
291
+ { action: 'delete', before: 'CLS', afterTpl: () => 'None = None' },
292
+ ] as const;
293
+
294
+ for (const entName of entityNames) {
295
+ const cls = className(entName);
296
+ for (const { action, before, afterTpl } of RECORD_VARIANTS) {
297
+ const cap = action.charAt(0).toUpperCase() + action.slice(1);
298
+ recordClasses.push(
299
+ [
300
+ `class ${cls}${cap}Record(BaseModel):`,
301
+ ` model_config = ConfigDict(extra="ignore")`,
302
+ ` namespace: Literal[${JSON.stringify(entName)}] = ${JSON.stringify(entName)}`,
303
+ ` id: str`,
304
+ ` action: Literal[${JSON.stringify(action)}] = ${JSON.stringify(action)}`,
305
+ ` before: ${before === 'CLS' ? cls : before}`,
306
+ ` after: ${afterTpl(cls)}`,
307
+ ` idempotencyKey: str`,
308
+ ].join('\n'),
309
+ );
310
+ }
311
+
312
+ namespaceUnions.push(
313
+ `${cls}Record = ${cls}CreateRecord | ${cls}UpdateRecord | ${cls}DeleteRecord`,
314
+ );
315
+
316
+ // Per-namespace handler TypedDict. Functional syntax so the `$default`
317
+ // key is expressible.
318
+ namespaceHandlerDicts.push(
319
+ [
320
+ `_${cls}Handlers = TypedDict(`,
321
+ ` "_${cls}Handlers",`,
322
+ ' {',
323
+ ` "create": Callable[[${cls}CreateRecord], Any],`,
324
+ ` "update": Callable[[${cls}UpdateRecord], Any],`,
325
+ ` "delete": Callable[[${cls}DeleteRecord], Any],`,
326
+ ` "$default": Callable[[${cls}Record], Any],`,
327
+ ' },',
328
+ ' total=False,',
329
+ ')',
330
+ ].join('\n'),
331
+ );
332
+ }
333
+
334
+ const rootUnionTerms = entityNames.map((n) => `${className(n)}Record`);
335
+ const rootUnion = `WebhookRecord = ${rootUnionTerms.join(' | ')}`;
336
+
337
+ const handlerDictLines = ['WebhookHandlers = TypedDict(', ' "WebhookHandlers",', ' {'];
338
+ for (const entName of entityNames) {
339
+ handlerDictLines.push(` ${JSON.stringify(entName)}: _${className(entName)}Handlers,`);
340
+ }
341
+ handlerDictLines.push(` "$default": Callable[[WebhookRecord], Any],`);
342
+ handlerDictLines.push(' },', ' total=False,', ')');
343
+
344
+ return [
345
+ '# ---------- webhook record models ----------',
346
+ '',
347
+ recordClasses.join('\n\n\n'),
348
+ '',
349
+ '',
350
+ '# ---------- webhook handler types ----------',
351
+ '',
352
+ namespaceUnions.join('\n'),
353
+ '',
354
+ rootUnion,
355
+ '',
356
+ '',
357
+ namespaceHandlerDicts.join('\n\n\n'),
358
+ '',
359
+ '',
360
+ handlerDictLines.join('\n'),
361
+ ].join('\n');
362
+ }
363
+
364
+ // ---------- tx builder .pyi stub ----------
365
+
366
+ export function buildTxStubPyi(schema: SchemaLike): string {
367
+ const entityNames = Object.keys(schema.entities).sort();
368
+ let usesDatetime = false;
369
+
370
+ // Per-entity link fields (label + arg type for link/unlink TypedDict).
371
+ const linkFieldsByEntity: Record<string, { label: string; type: string }[]> =
372
+ {};
373
+ for (const entName of entityNames) {
374
+ linkFieldsByEntity[entName] = [];
375
+ }
376
+ for (const link of Object.values(schema.links)) {
377
+ const fwdArgType = link.forward.has === 'one' ? 'str' : 'str | list[str]';
378
+ const revArgType = link.reverse.has === 'one' ? 'str' : 'str | list[str]';
379
+ if (linkFieldsByEntity[link.forward.on]) {
380
+ linkFieldsByEntity[link.forward.on].push({
381
+ label: link.forward.label,
382
+ type: fwdArgType,
383
+ });
384
+ }
385
+ if (linkFieldsByEntity[link.reverse.on]) {
386
+ linkFieldsByEntity[link.reverse.on].push({
387
+ label: link.reverse.label,
388
+ type: revArgType,
389
+ });
390
+ }
391
+ }
392
+ for (const entName of entityNames) {
393
+ linkFieldsByEntity[entName].sort((a, b) => a.label.localeCompare(b.label));
394
+ }
395
+
396
+ const perEntityBlocks: string[] = [];
397
+ for (const entName of entityNames) {
398
+ const cls = className(entName);
399
+
400
+ const argsLines = [`class ${cls}Args(TypedDict, total=False):`];
401
+ for (const [attrName, attrDef] of Object.entries(
402
+ schema.entities[entName].attrs,
403
+ )) {
404
+ const t = mapValueType(attrDef.valueType);
405
+ if (t === 'datetime') usesDatetime = true;
406
+ argsLines.push(` ${attrName}: ${t}`);
407
+ }
408
+ if (argsLines.length === 1) argsLines.push(' pass');
409
+ perEntityBlocks.push(argsLines.join('\n'));
410
+
411
+ const linkLines = [`class ${cls}Links(TypedDict, total=False):`];
412
+ for (const link of linkFieldsByEntity[entName]) {
413
+ linkLines.push(` ${link.label}: ${link.type}`);
414
+ }
415
+ if (linkLines.length === 1) linkLines.push(' pass');
416
+ perEntityBlocks.push(linkLines.join('\n'));
417
+
418
+ perEntityBlocks.push(
419
+ [
420
+ `class _${cls}Chunk:`,
421
+ ` def update(self, args: ${cls}Args) -> _${cls}Chunk: ...`,
422
+ ` def create(self, args: ${cls}Args) -> _${cls}Chunk: ...`,
423
+ ` def link(self, args: ${cls}Links) -> _${cls}Chunk: ...`,
424
+ ` def unlink(self, args: ${cls}Links) -> _${cls}Chunk: ...`,
425
+ ` def delete(self) -> _${cls}Chunk: ...`,
426
+ ` def merge(self, args: ${cls}Args) -> _${cls}Chunk: ...`,
427
+ ].join('\n'),
428
+ );
429
+ }
430
+
431
+ const builderBlock = [
432
+ 'class _NamespaceBuilder(Generic[ChunkT]):',
433
+ ' def __getitem__(self, eid: str) -> ChunkT: ...',
434
+ ' def lookup(self, attr: str, value: Any) -> ChunkT: ...',
435
+ ].join('\n');
436
+
437
+ // `$`-prefixed entities can't be reached via attribute syntax, so omit
438
+ // them from `_TxBuilder` attrs. Subscript form (`db.tx["$users"]`) still works.
439
+ const identifierEntities = entityNames.filter((n) => !n.startsWith('$'));
440
+ const txLines = ['class _TxBuilder:'];
441
+ if (identifierEntities.length === 0) {
442
+ txLines.push(' pass');
443
+ } else {
444
+ for (const entName of identifierEntities) {
445
+ txLines.push(` ${entName}: _NamespaceBuilder[_${className(entName)}Chunk]`);
446
+ }
447
+ }
448
+
449
+ const stdlibImports = ['from typing import Any, Generic, TypedDict, TypeVar'];
450
+ if (usesDatetime) stdlibImports.unshift('from datetime import datetime');
451
+
452
+ return [
453
+ PY_HEADER + '"""Tx builder typing stub. Applied via `Instant(schema=...)`."""',
454
+ '',
455
+ 'from __future__ import annotations',
456
+ '',
457
+ stdlibImports.join('\n'),
458
+ '',
459
+ '',
460
+ 'ChunkT = TypeVar("ChunkT")',
461
+ '',
462
+ '',
463
+ perEntityBlocks.join('\n\n\n'),
464
+ '',
465
+ '',
466
+ builderBlock,
467
+ '',
468
+ '',
469
+ txLines.join('\n'),
470
+ '',
471
+ ].join('\n');
472
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { infoCommand } from './commands/info.ts';
21
21
  import { pullCommand } from './commands/pull.ts';
22
22
  import type { SchemaPermsOrBoth } from './commands/pull.ts';
23
23
  import { claimCommand } from './commands/claim.ts';
24
+ import { genpyCommand } from './commands/genpy.ts';
24
25
  import { pushCommand } from './commands/push.ts';
25
26
  import { explorerCmd } from './commands/explorer.ts';
26
27
  import { queryCmd } from './commands/query.ts';
@@ -836,6 +837,28 @@ Environment Variables:
836
837
  );
837
838
  });
838
839
 
840
+ export const genpyDef = program
841
+ .command('genpy')
842
+ .description(
843
+ 'Generate Python types (instant_types.py + .pyi) from instant.schema.ts.',
844
+ )
845
+ .option(
846
+ '-o --out-dir <path>',
847
+ 'Directory to write generated files into. Defaults to the schema file directory.',
848
+ )
849
+ .addHelpText(
850
+ 'after',
851
+ `
852
+ Environment Variables:
853
+ INSTANT_SCHEMA_FILE_PATH Override schema file location (default: instant.schema.ts)
854
+ `,
855
+ )
856
+ .action(async (opts) => {
857
+ return runCommandEffect(
858
+ genpyCommand({ outDir: opts.outDir }).pipe(Effect.provide(BaseLayerLive)),
859
+ );
860
+ });
861
+
839
862
  export const claimDef = program
840
863
  .command('claim')
841
864
  .description('Transfer a temporary app into your Instant account')