@apso/cli 0.14.0 → 0.15.0

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.
@@ -3,13 +3,65 @@ export default class Dev extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ docker: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
6
7
  detach: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
8
  build: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
9
  };
10
+ private children;
9
11
  run(): Promise<void>;
10
12
  /**
11
- * Detect whether to use `docker compose` (v2 plugin) or `docker-compose` (v1).
12
- * Returns the command parts as an array.
13
+ * TypeScript: run `npm run dev` which reads DATABASE_TYPE from .env.
14
+ * PGlite is handled natively by the NestJS app -- no sidecar needed.
15
+ */
16
+ private runTypeScript;
17
+ /**
18
+ * Python: if pglite, start the pglite-server sidecar first, then uvicorn.
19
+ */
20
+ private runPython;
21
+ /**
22
+ * Go: if pglite, start the pglite-server sidecar first, then go run.
23
+ */
24
+ private runGo;
25
+ /**
26
+ * Fallback: run docker compose up (the old behavior).
27
+ */
28
+ private runDocker;
29
+ /**
30
+ * Detect the project language from .apsorc.
31
+ * Falls back to typescript if not specified.
32
+ */
33
+ private detectLanguage;
34
+ /**
35
+ * Read DATABASE_TYPE from .env. Defaults to pglite.
36
+ */
37
+ private readDatabaseType;
38
+ /**
39
+ * Read a single value from .env.
40
+ */
41
+ private readEnvValue;
42
+ /**
43
+ * Find .apsorc by walking up from cwd.
44
+ */
45
+ private findApsorc;
46
+ /**
47
+ * Run `npm install` if node_modules doesn't exist.
48
+ * Needed for the pglite-server sidecar in Python/Go projects.
49
+ */
50
+ private ensureNodeModules;
51
+ /**
52
+ * Wait for a TCP port to become reachable.
53
+ */
54
+ private waitForPort;
55
+ /**
56
+ * Spawn a process in the background and track it for cleanup.
57
+ */
58
+ private spawnBackground;
59
+ /**
60
+ * Spawn a process and wait for it to exit.
61
+ */
62
+ private spawnAndWait;
63
+ /**
64
+ * Detect whether to use `docker compose` (v2) or `docker-compose` (v1).
13
65
  */
14
66
  private detectComposeVariant;
15
67
  }
@@ -4,61 +4,246 @@ const tslib_1 = require("tslib");
4
4
  const core_1 = require("@oclif/core");
5
5
  const child_process_1 = require("child_process");
6
6
  const fs = tslib_1.__importStar(require("fs"));
7
+ const path = tslib_1.__importStar(require("path"));
7
8
  const shelljs_1 = tslib_1.__importDefault(require("shelljs"));
8
9
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
9
10
  class Dev extends base_command_1.default {
11
+ constructor() {
12
+ super(...arguments);
13
+ this.children = [];
14
+ }
10
15
  async run() {
11
16
  const { flags } = await this.parse(Dev);
12
- // Check Docker is installed
17
+ if (flags.docker) {
18
+ return this.runDocker(flags);
19
+ }
20
+ const language = this.detectLanguage();
21
+ const databaseType = this.readDatabaseType();
22
+ this.log(`Detected language: ${language}`);
23
+ this.log(`Database type: ${databaseType}`);
24
+ // Set up signal forwarding for clean shutdown
25
+ const cleanup = () => {
26
+ for (const child of this.children) {
27
+ child.kill("SIGTERM");
28
+ }
29
+ };
30
+ process.on("SIGINT", cleanup);
31
+ process.on("SIGTERM", cleanup);
32
+ try {
33
+ switch (language) {
34
+ case "typescript":
35
+ await this.runTypeScript();
36
+ break;
37
+ case "python":
38
+ await this.runPython(databaseType);
39
+ break;
40
+ case "go":
41
+ await this.runGo(databaseType);
42
+ break;
43
+ default:
44
+ this.error(`Unsupported language: ${language}. Supported: typescript, python, go.`);
45
+ }
46
+ }
47
+ finally {
48
+ process.removeListener("SIGINT", cleanup);
49
+ process.removeListener("SIGTERM", cleanup);
50
+ }
51
+ }
52
+ /**
53
+ * TypeScript: run `npm run dev` which reads DATABASE_TYPE from .env.
54
+ * PGlite is handled natively by the NestJS app -- no sidecar needed.
55
+ */
56
+ async runTypeScript() {
57
+ this.log("Starting TypeScript dev server...");
58
+ await this.spawnAndWait("npm", ["run", "dev"]);
59
+ }
60
+ /**
61
+ * Python: if pglite, start the pglite-server sidecar first, then uvicorn.
62
+ */
63
+ async runPython(databaseType) {
64
+ if (databaseType === "pglite") {
65
+ this.log("Starting PGlite sidecar...");
66
+ await this.ensureNodeModules();
67
+ this.spawnBackground("npm", ["run", "db"]);
68
+ // Give the sidecar a moment to bind the port
69
+ await this.waitForPort(5432, 10000);
70
+ }
71
+ this.log("Starting Python dev server...");
72
+ await this.spawnAndWait("uvicorn", [
73
+ "app.main:app",
74
+ "--reload",
75
+ "--host",
76
+ "0.0.0.0",
77
+ "--port",
78
+ this.readEnvValue("PORT") || "3000",
79
+ ]);
80
+ }
81
+ /**
82
+ * Go: if pglite, start the pglite-server sidecar first, then go run.
83
+ */
84
+ async runGo(databaseType) {
85
+ if (databaseType === "pglite") {
86
+ this.log("Starting PGlite sidecar...");
87
+ await this.ensureNodeModules();
88
+ this.spawnBackground("npm", ["run", "db"]);
89
+ await this.waitForPort(5432, 10000);
90
+ }
91
+ this.log("Starting Go dev server...");
92
+ await this.spawnAndWait("go", ["run", "cmd/main.go"]);
93
+ }
94
+ /**
95
+ * Fallback: run docker compose up (the old behavior).
96
+ */
97
+ async runDocker(flags) {
13
98
  if (!shelljs_1.default.which("docker")) {
14
99
  this.error("Docker is required but not found. Please install Docker and try again.\nhttps://docs.docker.com/get-docker/");
15
100
  }
16
- // Detect compose variant (v2 plugin vs v1 standalone)
17
101
  const composeCmd = this.detectComposeVariant();
18
- // Verify docker-compose.yml exists
19
102
  if (!fs.existsSync("docker-compose.yml")) {
20
103
  this.error("No docker-compose.yml found in the current directory.\nMake sure you are in an Apso project root.");
21
104
  }
22
- // Build the command args
23
105
  const args = [...composeCmd, "up"];
24
- if (flags.build) {
106
+ if (flags.build)
25
107
  args.push("--build");
26
- }
27
- if (flags.detach) {
108
+ if (flags.detach)
28
109
  args.push("-d");
29
- }
30
110
  this.log(`Running: ${args.join(" ")}`);
31
- const child = (0, child_process_1.spawn)(args[0], args.slice(1), {
111
+ await this.spawnAndWait(args[0], args.slice(1), {
112
+ AUTH_DISABLED: "true",
113
+ });
114
+ }
115
+ // --- Helpers ---
116
+ /**
117
+ * Detect the project language from .apsorc.
118
+ * Falls back to typescript if not specified.
119
+ */
120
+ detectLanguage() {
121
+ const apsorcPath = this.findApsorc();
122
+ if (!apsorcPath) {
123
+ this.log("No .apsorc found, defaulting to typescript.");
124
+ return "typescript";
125
+ }
126
+ try {
127
+ const content = fs.readFileSync(apsorcPath, "utf-8");
128
+ const config = JSON.parse(content);
129
+ return config.language || "typescript";
130
+ }
131
+ catch {
132
+ this.log("Could not parse .apsorc, defaulting to typescript.");
133
+ return "typescript";
134
+ }
135
+ }
136
+ /**
137
+ * Read DATABASE_TYPE from .env. Defaults to pglite.
138
+ */
139
+ readDatabaseType() {
140
+ return this.readEnvValue("DATABASE_TYPE") || "pglite";
141
+ }
142
+ /**
143
+ * Read a single value from .env.
144
+ */
145
+ readEnvValue(key) {
146
+ const envPath = path.resolve(".env");
147
+ if (!fs.existsSync(envPath))
148
+ return undefined;
149
+ const content = fs.readFileSync(envPath, "utf-8");
150
+ for (const line of content.split("\n")) {
151
+ const trimmed = line.trim();
152
+ if (trimmed.startsWith("#") || !trimmed.includes("="))
153
+ continue;
154
+ const [k, ...rest] = trimmed.split("=");
155
+ if (k.trim() === key)
156
+ return rest.join("=").trim();
157
+ }
158
+ return undefined;
159
+ }
160
+ /**
161
+ * Find .apsorc by walking up from cwd.
162
+ */
163
+ findApsorc() {
164
+ let dir = process.cwd();
165
+ while (dir !== path.parse(dir).root) {
166
+ const candidate = path.join(dir, ".apsorc");
167
+ if (fs.existsSync(candidate))
168
+ return candidate;
169
+ dir = path.dirname(dir);
170
+ }
171
+ return null;
172
+ }
173
+ /**
174
+ * Run `npm install` if node_modules doesn't exist.
175
+ * Needed for the pglite-server sidecar in Python/Go projects.
176
+ */
177
+ async ensureNodeModules() {
178
+ if (!fs.existsSync("node_modules")) {
179
+ this.log("Installing pglite-server dependencies...");
180
+ await this.spawnAndWait("npm", ["install"]);
181
+ }
182
+ }
183
+ /**
184
+ * Wait for a TCP port to become reachable.
185
+ */
186
+ waitForPort(port, timeoutMs) {
187
+ return new Promise((resolve, reject) => {
188
+ const start = Date.now();
189
+ const check = () => {
190
+ const net = require("net");
191
+ const sock = net.createConnection({ port, host: "127.0.0.1" }, () => {
192
+ sock.destroy();
193
+ resolve();
194
+ });
195
+ sock.on("error", () => {
196
+ sock.destroy();
197
+ if (Date.now() - start > timeoutMs) {
198
+ reject(new Error(`Port ${port} did not become available within ${timeoutMs}ms`));
199
+ }
200
+ else {
201
+ setTimeout(check, 250);
202
+ }
203
+ });
204
+ };
205
+ check();
206
+ });
207
+ }
208
+ /**
209
+ * Spawn a process in the background and track it for cleanup.
210
+ */
211
+ spawnBackground(cmd, args) {
212
+ const child = (0, child_process_1.spawn)(cmd, args, {
32
213
  stdio: "inherit",
33
- env: {
34
- ...process.env,
35
- AUTH_DISABLED: "true",
36
- },
214
+ env: { ...process.env },
37
215
  });
38
- // Forward signals for clean shutdown
39
- const forwardSignal = (signal) => {
40
- child.kill(signal);
41
- };
42
- process.on("SIGINT", forwardSignal);
43
- process.on("SIGTERM", forwardSignal);
216
+ this.children.push(child);
217
+ child.on("error", (err) => {
218
+ this.warn(`Background process ${cmd} failed: ${err.message}`);
219
+ });
220
+ return child;
221
+ }
222
+ /**
223
+ * Spawn a process and wait for it to exit.
224
+ */
225
+ spawnAndWait(cmd, args, extraEnv) {
44
226
  return new Promise((resolve, reject) => {
227
+ const child = (0, child_process_1.spawn)(cmd, args, {
228
+ stdio: "inherit",
229
+ env: { ...process.env, ...extraEnv },
230
+ });
231
+ this.children.push(child);
45
232
  child.on("close", (code) => {
46
- process.removeListener("SIGINT", forwardSignal);
47
- process.removeListener("SIGTERM", forwardSignal);
233
+ this.children = this.children.filter((c) => c !== child);
48
234
  if (code !== 0 && code !== null) {
49
- reject(new Error(`docker compose exited with code ${code}`));
235
+ reject(new Error(`${cmd} exited with code ${code}`));
50
236
  return;
51
237
  }
52
238
  resolve();
53
239
  });
54
240
  child.on("error", (err) => {
55
- this.error(`Failed to start Docker Compose: ${err.message}`);
241
+ this.error(`Failed to start ${cmd}: ${err.message}`);
56
242
  });
57
243
  });
58
244
  }
59
245
  /**
60
- * Detect whether to use `docker compose` (v2 plugin) or `docker-compose` (v1).
61
- * Returns the command parts as an array.
246
+ * Detect whether to use `docker compose` (v2) or `docker-compose` (v1).
62
247
  */
63
248
  detectComposeVariant() {
64
249
  try {
@@ -66,7 +251,6 @@ class Dev extends base_command_1.default {
66
251
  return ["docker", "compose"];
67
252
  }
68
253
  catch {
69
- // Fall back to v1
70
254
  if (shelljs_1.default.which("docker-compose")) {
71
255
  return ["docker-compose"];
72
256
  }
@@ -74,21 +258,24 @@ class Dev extends base_command_1.default {
74
258
  }
75
259
  }
76
260
  }
77
- Dev.description = "Start the local development server using Docker Compose";
261
+ Dev.description = "Start the local development server. Detects project language and database type from .apsorc and .env.";
78
262
  Dev.examples = [
79
263
  `$ apso dev`,
80
- `$ apso dev --build`,
81
- `$ apso dev --detach`,
82
- `$ apso dev --build --detach`,
264
+ `$ apso dev --docker`,
265
+ `$ apso dev --docker --build`,
83
266
  ];
84
267
  Dev.flags = {
268
+ docker: core_1.Flags.boolean({
269
+ description: "Use Docker Compose instead of the native dev stack",
270
+ default: false,
271
+ }),
85
272
  detach: core_1.Flags.boolean({
86
273
  char: "d",
87
- description: "Run containers in the background",
274
+ description: "Run containers in the background (Docker mode only)",
88
275
  default: false,
89
276
  }),
90
277
  build: core_1.Flags.boolean({
91
- description: "Rebuild images before starting",
278
+ description: "Rebuild images before starting (Docker mode only)",
92
279
  default: false,
93
280
  }),
94
281
  };
@@ -55,7 +55,7 @@ class Init extends base_command_1.default {
55
55
  // Select workspace
56
56
  const workspaces = await services_1.workspacesApi.list();
57
57
  if (workspaces.length === 0) {
58
- this.error("No workspaces found. Create a workspace at https://app.apso.ai first.");
58
+ this.error("No workspaces found. Create a workspace at https://app.apso.cloud first.");
59
59
  }
60
60
  const { workspace } = await inquirer_1.default.prompt([
61
61
  {
@@ -170,7 +170,7 @@ class Init extends base_command_1.default {
170
170
  // Select workspace
171
171
  const workspaces = await services_1.workspacesApi.list();
172
172
  if (workspaces.length === 0) {
173
- this.error("No workspaces found. Create a workspace at https://app.apso.ai first.");
173
+ this.error("No workspaces found. Create a workspace at https://app.apso.cloud first.");
174
174
  }
175
175
  const { workspace } = await inquirer_1.default.prompt([
176
176
  {
@@ -44,7 +44,7 @@ class Link extends base_command_1.default {
44
44
  else {
45
45
  const workspaces = await services_1.workspacesApi.list();
46
46
  if (workspaces.length === 0) {
47
- this.error("No workspaces found. Create a workspace at https://app.apso.ai first.");
47
+ this.error("No workspaces found. Create a workspace at https://app.apso.cloud first.");
48
48
  }
49
49
  const response = await inquirer_1.default.prompt([
50
50
  {
@@ -28,7 +28,7 @@ class Projects extends base_command_1.default {
28
28
  // Prompt
29
29
  const workspaces = await services_1.workspacesApi.list();
30
30
  if (workspaces.length === 0) {
31
- this.error("No workspaces found. Create a workspace at https://app.apso.ai first.");
31
+ this.error("No workspaces found. Create a workspace at https://app.apso.cloud first.");
32
32
  }
33
33
  const response = await inquirer_1.default.prompt([
34
34
  {
@@ -11,8 +11,8 @@ exports.CONFIG_ENV_VARS = exports.DEFAULT_GLOBAL_CONFIG = void 0;
11
11
  * Default values for global configuration
12
12
  */
13
13
  exports.DEFAULT_GLOBAL_CONFIG = {
14
- apiUrl: "https://api.apso.ai",
15
- webUrl: "https://app.apso.ai",
14
+ apiUrl: "https://api.apso.dev",
15
+ webUrl: "https://app.apso.cloud",
16
16
  verbose: false,
17
17
  noColor: false,
18
18
  telemetryDisabled: false,
@@ -18,6 +18,7 @@ const paths_1 = require("../config/paths");
18
18
  const snapshot_1 = require("./snapshot");
19
19
  const field_1 = require("../utils/field");
20
20
  const casing_1 = require("../utils/casing");
21
+ const relationships_1 = require("../utils/relationships");
21
22
  /**
22
23
  * Get the sandbox data directory for PGlite persistence.
23
24
  */
@@ -57,11 +58,12 @@ async function resetPGliteSingleton() {
57
58
  * Clears TypeORM's global metadata storage first to prevent cross-contamination
58
59
  * between runs.
59
60
  */
60
- function buildEntityClasses(entities) {
61
+ function buildEntityClasses(entities, relationshipMap = {}) {
61
62
  var _a, _b;
62
63
  clearTypeOrmMetadata();
63
64
  const typeorm = require("typeorm");
64
65
  const classes = [];
66
+ const classMap = new Map();
65
67
  for (const entityDef of entities) {
66
68
  const className = entityDef.name;
67
69
  const tableName = (0, casing_1.snakeCase)(entityDef.name);
@@ -122,9 +124,125 @@ function buildEntityClasses(entities) {
122
124
  }
123
125
  }
124
126
  classes.push(EntityClass);
127
+ classMap.set(entityDef.name, EntityClass);
128
+ }
129
+ for (const entityDef of entities) {
130
+ const EntityClass = classMap.get(entityDef.name);
131
+ if (!EntityClass)
132
+ continue;
133
+ const relationships = (0, relationships_1.getRelationshipForTemplate)(entityDef.name, relationshipMap[entityDef.name] || [], entities);
134
+ for (const relationship of relationships) {
135
+ const targetClass = classMap.get(relationship.name);
136
+ if (!targetClass)
137
+ continue;
138
+ const inversePropertyName = relationship.inverseSidePropertyName ||
139
+ relationship.inversePropertyName ||
140
+ entityDef.name;
141
+ const relationOptions = {};
142
+ if (relationship.nullable !== undefined) {
143
+ relationOptions.nullable = relationship.nullable;
144
+ }
145
+ if (relationship.cascadeDelete) {
146
+ relationOptions.onDelete = "CASCADE";
147
+ }
148
+ if (relationship.type === "ManyToOne") {
149
+ if (relationship.index) {
150
+ typeorm.Index()(EntityClass.prototype, relationship.relationshipName);
151
+ }
152
+ const inverseSide = relationship.biDirectional
153
+ ? (target) => target[inversePropertyName]
154
+ : undefined;
155
+ typeorm.ManyToOne(() => targetClass, inverseSide, relationOptions)(EntityClass.prototype, relationship.relationshipName);
156
+ typeorm.JoinColumn({ name: relationship.camelCasedId })(EntityClass.prototype, relationship.relationshipName);
157
+ }
158
+ if (relationship.type === "OneToMany" && relationship.biDirectional) {
159
+ typeorm.OneToMany(() => targetClass, (target) => target[inversePropertyName])(EntityClass.prototype, relationship.pluralizedRelationshipName);
160
+ }
161
+ if (relationship.type === "ManyToMany") {
162
+ const inverseSide = relationship.biDirectional
163
+ ? (target) => target[inversePropertyName]
164
+ : undefined;
165
+ typeorm.ManyToMany(() => targetClass, inverseSide)(EntityClass.prototype, relationship.pluralizedRelationshipName);
166
+ if (relationship.joinTable) {
167
+ const joinTableOptions = {};
168
+ if (relationship.joinTableName) {
169
+ joinTableOptions.name = relationship.joinTableName;
170
+ }
171
+ if (relationship.joinColumnName) {
172
+ joinTableOptions.joinColumn = {
173
+ name: relationship.joinColumnName,
174
+ referencedColumnName: "id",
175
+ };
176
+ }
177
+ if (relationship.inverseJoinColumnName) {
178
+ joinTableOptions.inverseJoinColumn = {
179
+ name: relationship.inverseJoinColumnName,
180
+ referencedColumnName: "id",
181
+ };
182
+ }
183
+ typeorm.JoinTable(Object.keys(joinTableOptions).length > 0
184
+ ? joinTableOptions
185
+ : undefined)(EntityClass.prototype, relationship.pluralizedRelationshipName);
186
+ }
187
+ }
188
+ if (relationship.type === "OneToOne") {
189
+ const inverseSide = relationship.biDirectional
190
+ ? (target) => target[inversePropertyName]
191
+ : undefined;
192
+ typeorm.OneToOne(() => targetClass, inverseSide, relationOptions)(EntityClass.prototype, relationship.relationshipName);
193
+ if (relationship.joinTable) {
194
+ typeorm.JoinColumn()(EntityClass.prototype, relationship.relationshipName);
195
+ }
196
+ }
197
+ }
125
198
  }
126
199
  return classes;
127
200
  }
201
+ function snapshotRelationships(relationshipMap) {
202
+ return Object.entries(relationshipMap)
203
+ .flatMap(([from, relationships]) => relationships.map((relationship) => {
204
+ var _a;
205
+ return ({
206
+ from,
207
+ to: relationship.name,
208
+ type: relationship.type,
209
+ to_name: (_a = relationship.referenceName) !== null && _a !== void 0 ? _a : undefined,
210
+ nullable: relationship.nullable,
211
+ bi_directional: relationship.biDirectional,
212
+ cascadeDelete: relationship.cascadeDelete,
213
+ index: relationship.index,
214
+ join: relationship.join,
215
+ joinTableName: relationship.joinTableName,
216
+ joinColumnName: relationship.joinColumnName,
217
+ inverseJoinColumnName: relationship.inverseJoinColumnName,
218
+ inverseReferenceName: relationship.inverseReferenceName,
219
+ });
220
+ }))
221
+ .sort((a, b) => `${a.from}:${a.to}:${a.type}:${a.to_name || ""}`.localeCompare(`${b.from}:${b.to}:${b.type}:${b.to_name || ""}`));
222
+ }
223
+ function relationshipMapFromSnapshot(relationships = []) {
224
+ const relationshipMap = {};
225
+ for (const relationship of relationships) {
226
+ relationshipMap[relationship.from] = [
227
+ ...(relationshipMap[relationship.from] || []),
228
+ {
229
+ name: relationship.to,
230
+ type: relationship.type,
231
+ referenceName: relationship.to_name,
232
+ nullable: relationship.nullable,
233
+ biDirectional: relationship.bi_directional,
234
+ cascadeDelete: relationship.cascadeDelete,
235
+ index: relationship.index,
236
+ join: relationship.join,
237
+ joinTableName: relationship.joinTableName,
238
+ joinColumnName: relationship.joinColumnName,
239
+ inverseJoinColumnName: relationship.inverseJoinColumnName,
240
+ inverseReferenceName: relationship.inverseReferenceName,
241
+ },
242
+ ];
243
+ }
244
+ return relationshipMap;
245
+ }
128
246
  /**
129
247
  * Create a TypeORM DataSource configured for PGlite with in-memory entity classes.
130
248
  */
@@ -185,6 +303,7 @@ async function runMigrationSandbox(current, projectDir) {
185
303
  scopeBy: e.scopeBy,
186
304
  });
187
305
  }),
306
+ relationships: snapshotRelationships(current.relationshipMap),
188
307
  };
189
308
  // Quick check: has anything changed?
190
309
  if (snapshot && !schemaChanged(currentSnapshot, snapshot)) {
@@ -221,7 +340,7 @@ async function runMigrationSandbox(current, projectDir) {
221
340
  : [];
222
341
  // Step 1: Establish baseline with previous schema
223
342
  if (previousEntities.length > 0) {
224
- const prevClasses = buildEntityClasses(previousEntities);
343
+ const prevClasses = buildEntityClasses(previousEntities, relationshipMapFromSnapshot(snapshot === null || snapshot === void 0 ? void 0 : snapshot.relationships));
225
344
  const prevDs = await createPGliteDataSource(prevClasses);
226
345
  await prevDs.initialize();
227
346
  await prevDs.synchronize();
@@ -230,7 +349,7 @@ async function runMigrationSandbox(current, projectDir) {
230
349
  // The PGlite in-memory database persists in the singleton.
231
350
  }
232
351
  // Step 2: Compute diff with current schema
233
- const currentClasses = buildEntityClasses(current.entities);
352
+ const currentClasses = buildEntityClasses(current.entities, current.relationshipMap);
234
353
  const currentDs = await createPGliteDataSource(currentClasses);
235
354
  await currentDs.initialize();
236
355
  try {
@@ -314,6 +433,7 @@ function applyMigration(current, projectDir) {
314
433
  scopeBy: e.scopeBy,
315
434
  });
316
435
  }),
436
+ relationships: snapshotRelationships(current.relationshipMap),
317
437
  };
318
438
  (0, snapshot_1.writeSnapshot)(snapshot, projectDir);
319
439
  }
@@ -333,5 +453,7 @@ exports.resetSandbox = resetSandbox;
333
453
  * Simple deep comparison between current and snapshot schemas.
334
454
  */
335
455
  function schemaChanged(current, snapshot) {
336
- return (JSON.stringify(current.entities) !== JSON.stringify(snapshot.entities));
456
+ return (JSON.stringify(current.entities) !== JSON.stringify(snapshot.entities) ||
457
+ JSON.stringify(current.relationships || []) !==
458
+ JSON.stringify(snapshot.relationships || []));
337
459
  }
@@ -57,6 +57,7 @@ export interface ComparableSchema {
57
57
  updated_at?: boolean;
58
58
  scopeBy?: string | string[];
59
59
  }>;
60
+ relationships?: ApsorcSnapshot["relationships"];
60
61
  }
61
62
  /**
62
63
  * Read the saved schema snapshot, or null if none exists.
@@ -95,6 +95,9 @@ function hasChanges(current, snapshot) {
95
95
  return true;
96
96
  }
97
97
  }
98
+ if (normalize(current.relationships || []) !== normalize(snapshot.relationships || [])) {
99
+ return true;
100
+ }
98
101
  return false;
99
102
  }
100
103
  exports.hasChanges = hasChanges;
@@ -222,19 +222,21 @@ func applyOperator(query *gorm.DB, field, op, value string, useOr bool) *gorm.DB
222
222
  where = query.Or
223
223
  }
224
224
 
225
+ cv := coerceValue(value)
226
+
225
227
  switch op {
226
228
  case "$eq":
227
- return where(fmt.Sprintf("%s = ?", field), value)
229
+ return where(fmt.Sprintf("%s = ?", field), cv)
228
230
  case "$ne":
229
- return where(fmt.Sprintf("%s != ?", field), value)
231
+ return where(fmt.Sprintf("%s != ?", field), cv)
230
232
  case "$gt":
231
- return where(fmt.Sprintf("%s > ?", field), value)
233
+ return where(fmt.Sprintf("%s > ?", field), cv)
232
234
  case "$gte":
233
- return where(fmt.Sprintf("%s >= ?", field), value)
235
+ return where(fmt.Sprintf("%s >= ?", field), cv)
234
236
  case "$lt":
235
- return where(fmt.Sprintf("%s < ?", field), value)
237
+ return where(fmt.Sprintf("%s < ?", field), cv)
236
238
  case "$lte":
237
- return where(fmt.Sprintf("%s <= ?", field), value)
239
+ return where(fmt.Sprintf("%s <= ?", field), cv)
238
240
  case "$starts":
239
241
  return where(fmt.Sprintf("%s LIKE ?", field), value+"%")
240
242
  case "$ends":
@@ -244,9 +246,9 @@ func applyOperator(query *gorm.DB, field, op, value string, useOr bool) *gorm.DB
244
246
  case "$excl":
245
247
  return where(fmt.Sprintf("%s NOT LIKE ?", field), "%"+value+"%")
246
248
  case "$in":
247
- return where(fmt.Sprintf("%s IN ?", field), splitValue(value))
249
+ return where(fmt.Sprintf("%s IN ?", field), coerceValues(splitValue(value)))
248
250
  case "$notin":
249
- return where(fmt.Sprintf("%s NOT IN ?", field), splitValue(value))
251
+ return where(fmt.Sprintf("%s NOT IN ?", field), coerceValues(splitValue(value)))
250
252
  case "$isnull":
251
253
  return where(fmt.Sprintf("%s IS NULL", field))
252
254
  case "$notnull":
@@ -254,7 +256,7 @@ func applyOperator(query *gorm.DB, field, op, value string, useOr bool) *gorm.DB
254
256
  case "$between":
255
257
  vals := splitValue(value)
256
258
  if len(vals) >= 2 {
257
- return where(fmt.Sprintf("%s BETWEEN ? AND ?", field), vals[0], vals[1])
259
+ return where(fmt.Sprintf("%s BETWEEN ? AND ?", field), coerceValue(vals[0]), coerceValue(vals[1]))
258
260
  }
259
261
  return query
260
262
  case "$eqL":
@@ -299,3 +301,29 @@ func splitValue(value string) []string {
299
301
  }
300
302
  return result
301
303
  }
304
+
305
+ // coerceValue converts a string query parameter to a typed value.
306
+ // Booleans and numbers are converted so the database driver sends the
307
+ // correct wire type instead of a raw string.
308
+ func coerceValue(value string) interface{} {
309
+ low := strings.ToLower(value)
310
+ if low == "true" || low == "false" || low == "yes" || low == "no" || low == "1" || low == "0" {
311
+ return low == "true" || low == "yes" || low == "1"
312
+ }
313
+ if v, err := strconv.ParseInt(value, 10, 64); err == nil {
314
+ return v
315
+ }
316
+ if v, err := strconv.ParseFloat(value, 64); err == nil {
317
+ return v
318
+ }
319
+ return value
320
+ }
321
+
322
+ // coerceValues applies coerceValue to each element in a slice.
323
+ func coerceValues(values []string) []interface{} {
324
+ result := make([]interface{}, len(values))
325
+ for i, v := range values {
326
+ result[i] = coerceValue(v)
327
+ }
328
+ return result
329
+ }
@@ -6,7 +6,7 @@ import math
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, TypeVar
8
8
 
9
- from sqlalchemy import asc, desc, func, or_
9
+ from sqlalchemy import Boolean, Date, DateTime, Float, Integer, Numeric, asc, desc, func, or_
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
  from sqlalchemy.orm import selectinload
12
12
  from sqlalchemy.sql import Select, select
@@ -65,30 +65,58 @@ class PaginatedResult:
65
65
  # Operator registry
66
66
  # ---------------------------------------------------------------------------
67
67
 
68
+ def _coerce_value(col: Any, value: str) -> Any:
69
+ """Coerce a string query param value to match the column's SQL type."""
70
+ try:
71
+ col_type = col.property.columns[0].type
72
+ except (AttributeError, IndexError):
73
+ return value
74
+
75
+ if isinstance(col_type, Boolean):
76
+ return value.lower() in ("true", "1", "yes")
77
+ if isinstance(col_type, (Integer,)):
78
+ try:
79
+ return int(value)
80
+ except (ValueError, TypeError):
81
+ return value
82
+ if isinstance(col_type, (Float, Numeric)):
83
+ try:
84
+ return float(value)
85
+ except (ValueError, TypeError):
86
+ return value
87
+ return value
88
+
89
+
90
+ def _coerce_list(col: Any, values: List[str]) -> List[Any]:
91
+ """Coerce a list of string values to match the column's SQL type."""
92
+ return [_coerce_value(col, v) for v in values]
93
+
94
+
68
95
  def _apply_operator(model: Any, field_name: str, op: str, value: str) -> Any:
69
96
  """Return a SQLAlchemy filter expression for the given operator."""
70
97
  col = getattr(model, field_name, None)
71
98
  if col is None:
72
99
  return None
73
100
 
101
+ typed_value = _coerce_value(col, value)
74
102
  lower_col = func.lower(col)
75
103
 
76
104
  operators: Dict[str, Any] = {
77
- "$eq": lambda: col == value,
78
- "$ne": lambda: col != value,
79
- "$gt": lambda: col > value,
80
- "$gte": lambda: col >= value,
81
- "$lt": lambda: col < value,
82
- "$lte": lambda: col <= value,
105
+ "$eq": lambda: col == typed_value,
106
+ "$ne": lambda: col != typed_value,
107
+ "$gt": lambda: col > typed_value,
108
+ "$gte": lambda: col >= typed_value,
109
+ "$lt": lambda: col < typed_value,
110
+ "$lte": lambda: col <= typed_value,
83
111
  "$starts": lambda: col.like(f"{value}%"),
84
112
  "$ends": lambda: col.like(f"%{value}"),
85
113
  "$cont": lambda: col.like(f"%{value}%"),
86
114
  "$excl": lambda: ~col.like(f"%{value}%"),
87
- "$in": lambda: col.in_(_split_value(value)),
88
- "$notin": lambda: ~col.in_(_split_value(value)),
115
+ "$in": lambda: col.in_(_coerce_list(col, _split_value(value))),
116
+ "$notin": lambda: ~col.in_(_coerce_list(col, _split_value(value))),
89
117
  "$isnull": lambda: col.is_(None),
90
118
  "$notnull": lambda: col.isnot(None),
91
- "$between": lambda: col.between(*_split_value(value)[:2]),
119
+ "$between": lambda: col.between(*_coerce_list(col, _split_value(value)[:2])),
92
120
  "$eqL": lambda: lower_col == value.lower(),
93
121
  "$neL": lambda: lower_col != value.lower(),
94
122
  "$startsL": lambda: lower_col.like(f"{value.lower()}%"),
@@ -6,7 +6,7 @@ const fs = tslib_1.__importStar(require("fs"));
6
6
  const path = tslib_1.__importStar(require("path"));
7
7
  const shelljs_1 = tslib_1.__importDefault(require("shelljs"));
8
8
  exports.TEMPLATE_REPOS = {
9
- typescript: "https://github.com/apsoai/service-template.git",
9
+ typescript: "https://github.com/apsoai/service-template-ts.git",
10
10
  python: "https://github.com/apsoai/service-template-python.git",
11
11
  go: "https://github.com/apsoai/service-template-go.git",
12
12
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.14.0",
9
+ "version": "0.15.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@electric-sql/pglite": "^0.2.17",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.14.0",
2
+ "version": "0.15.0",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
@@ -73,7 +73,7 @@
73
73
  },
74
74
  "dev": {
75
75
  "id": "dev",
76
- "description": "Start the local development server using Docker Compose",
76
+ "description": "Start the local development server. Detects project language and database type from .apsorc and .env.",
77
77
  "strict": true,
78
78
  "pluginName": "@apso/cli",
79
79
  "pluginAlias": "@apso/cli",
@@ -81,22 +81,27 @@
81
81
  "aliases": [],
82
82
  "examples": [
83
83
  "$ apso dev",
84
- "$ apso dev --build",
85
- "$ apso dev --detach",
86
- "$ apso dev --build --detach"
84
+ "$ apso dev --docker",
85
+ "$ apso dev --docker --build"
87
86
  ],
88
87
  "flags": {
88
+ "docker": {
89
+ "name": "docker",
90
+ "type": "boolean",
91
+ "description": "Use Docker Compose instead of the native dev stack",
92
+ "allowNo": false
93
+ },
89
94
  "detach": {
90
95
  "name": "detach",
91
96
  "type": "boolean",
92
97
  "char": "d",
93
- "description": "Run containers in the background",
98
+ "description": "Run containers in the background (Docker mode only)",
94
99
  "allowNo": false
95
100
  },
96
101
  "build": {
97
102
  "name": "build",
98
103
  "type": "boolean",
99
- "description": "Rebuild images before starting",
104
+ "description": "Rebuild images before starting (Docker mode only)",
100
105
  "allowNo": false
101
106
  }
102
107
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",