Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions lib/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,8 +1373,12 @@ export class Push {
}> {
const { attempts, skipConfirmation = false } = options;
const pollMaxDebounces = attempts ?? POLL_DEFAULT_VALUE;
const pools = new Pools(pollMaxDebounces);
const attributes = new Attributes(pools, skipConfirmation);
const pools = new Pools(pollMaxDebounces, this.projectClient);
const attributes = new Attributes(
pools,
skipConfirmation,
this.projectClient,
);

let tablesChanged = new Set();
const errors: any[] = [];
Expand Down Expand Up @@ -1506,8 +1510,12 @@ export class Push {
errors: Error[];
}> {
const { skipConfirmation = false } = options;
const pools = new Pools(POLL_DEFAULT_VALUE);
const attributesHelper = new Attributes(pools, skipConfirmation);
const pools = new Pools(POLL_DEFAULT_VALUE, this.projectClient);
const attributesHelper = new Attributes(
pools,
skipConfirmation,
this.projectClient,
);

const errors: Error[] = [];

Expand Down
18 changes: 12 additions & 6 deletions lib/commands/utils/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { KeysAttributes } from "../../config.js";
import { log, success, error, cliConfig, drawTable } from "../../parser.js";
import { Pools } from "./pools.js";
import inquirer from "inquirer";
import type { Client } from "@appwrite.io/console";

const changeableKeys = [
"status",
Expand Down Expand Up @@ -53,12 +54,17 @@ const questionPushChangesConfirmation = [
export class Attributes {
private pools: Pools;
private skipConfirmation: boolean;
private projectClient?: Client;

constructor(pools?: Pools, skipConfirmation = false) {
this.pools = pools || new Pools();
constructor(pools?: Pools, skipConfirmation = false, projectClient?: Client) {
this.pools = pools || new Pools(undefined, projectClient);
this.skipConfirmation = skipConfirmation;
this.projectClient = projectClient;
}

private getDatabasesService = async () =>
getDatabasesService(this.projectClient);

private getConfirmation = async (): Promise<boolean> => {
if (cliConfig.force || this.skipConfirmation) {
return true;
Expand Down Expand Up @@ -197,7 +203,7 @@ export class Attributes {
collectionId: string,
attribute: any,
): Promise<any> => {
const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
switch (attribute.type) {
case "string":
switch (attribute.format) {
Expand Down Expand Up @@ -373,7 +379,7 @@ export class Attributes {
collectionId: string,
attribute: any,
): Promise<any> => {
const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
switch (attribute.type) {
case "string":
switch (attribute.format) {
Expand Down Expand Up @@ -533,7 +539,7 @@ export class Attributes {
`Deleting ${isIndex ? "index" : "attribute"} ${attribute.key} of ${collection.name} ( ${collection["$id"]} )`,
);

const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
if (isIndex) {
await databasesService.deleteIndex(
collection["databaseId"],
Expand Down Expand Up @@ -733,7 +739,7 @@ export class Attributes {
): Promise<void> => {
log(`Creating indexes ...`);

const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
for (let index of indexes) {
await databasesService.createIndex({
databaseId: collection["databaseId"],
Expand Down
18 changes: 12 additions & 6 deletions lib/commands/utils/pools.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { getDatabasesService } from "../../services.js";
import { paginate } from "../../paginate.js";
import { log } from "../../parser.js";
import type { Client } from "@appwrite.io/console";

export class Pools {
private STEP_SIZE = 100; // Resources
private POLL_DEBOUNCE = 2000; // Milliseconds
private pollMaxDebounces = 30;
private POLL_DEFAULT_VALUE = 30;
private projectClient?: Client;

constructor(pollMaxDebounces?: number) {
constructor(pollMaxDebounces?: number, projectClient?: Client) {
if (pollMaxDebounces) {
this.pollMaxDebounces = pollMaxDebounces;
}
this.projectClient = projectClient;
}

private getDatabasesService = async () =>
getDatabasesService(this.projectClient);

public wipeAttributes = async (
databaseId: string,
collectionId: string,
Expand All @@ -23,7 +29,7 @@ export class Pools {
return false;
}

const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
const response = await databasesService.listAttributes(
databaseId,
collectionId,
Expand Down Expand Up @@ -62,7 +68,7 @@ export class Pools {
return false;
}

const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
const response = await databasesService.listIndexes(
databaseId,
collectionId,
Expand Down Expand Up @@ -117,7 +123,7 @@ export class Pools {

const { attributes } = await paginate(
async (args: any) => {
const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
return await databasesService.listAttributes({
databaseId: args.databaseId,
collectionId: args.collectionId,
Expand Down Expand Up @@ -175,7 +181,7 @@ export class Pools {

const { attributes } = await paginate(
async (args: any) => {
const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
return await databasesService.listAttributes(
args.databaseId,
args.collectionId,
Expand Down Expand Up @@ -243,7 +249,7 @@ export class Pools {

const { indexes } = await paginate(
async (args: any) => {
const databasesService = await getDatabasesService();
const databasesService = await this.getDatabasesService();
return await databasesService.listIndexes(
args.databaseId,
args.collectionId,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
"generate": "tsx scripts/generate-commands.ts",
"prepublishOnly": "npm run build",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "bun test",
"linux-x64": "bun build cli.ts --compile --sourcemap=inline --target=bun-linux-x64 --outfile build/appwrite-cli-linux-x64",
"linux-arm64": "bun build cli.ts --compile --sourcemap=inline --target=bun-linux-arm64 --outfile build/appwrite-cli-linux-arm64",
"mac-x64": "bun build cli.ts --compile --sourcemap=inline --target=bun-darwin-x64 --outfile build/appwrite-cli-darwin-x64",
Expand Down
109 changes: 109 additions & 0 deletions tests/push.programmatic.tables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, test, mock } from "bun:test";

describe("Push.pushTables programmatic mode", () => {
test("uses injected project client for table columns and indexes", async () => {
const projectClient = { _tag: "project-client" } as any;
const consoleClient = { _tag: "console-client" } as any;

const databaseServiceClientArgs: any[] = [];
const tableServiceClientArgs: any[] = [];

mock.module("../lib/services.js", () => {
const databasesService = {
createStringAttribute: async () => ({}),
createIndex: async () => ({}),
listAttributes: async () => ({
total: 1,
attributes: [{ key: "title", status: "available" }],
}),
listIndexes: async () => ({
total: 1,
indexes: [{ key: "idx_title", status: "available" }],
}),
};

const tablesService = {
getTable: async () => {
throw { code: 404 };
},
createTable: async () => ({}),
};

const throwIfNoProjectClient = (sdk?: any) => {
if (!sdk) {
throw new Error(
"Project is not set. Please run `appwrite init project`.",
);
}
};

return {
getProxyService: async () => ({}),
getConsoleService: async () => ({}),
getFunctionsService: async () => ({}),
getSitesService: async () => ({}),
getStorageService: async () => ({}),
getMessagingService: async () => ({}),
getOrganizationsService: async () => ({}),
getTeamsService: async () => ({}),
getProjectsService: async () => ({}),
getDatabasesService: async (sdk?: any) => {
databaseServiceClientArgs.push(sdk);
throwIfNoProjectClient(sdk);
return databasesService as any;
},
getTablesDBService: async (sdk?: any) => {
tableServiceClientArgs.push(sdk);
throwIfNoProjectClient(sdk);
return tablesService as any;
},
};
});

const { Push } = await import("../lib/commands/push.ts");
Comment on lines +11 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

mock.module inside a test() body can leak to other test files.

As of Bun v1.0.19, Bun resolves the specifier passed to mock.module() as though it were an import, using the resolved path as the module-cache key. So "../lib/services.js" from tests/ correctly resolves to lib/services.js, matching the same module imported by push.ts, attributes.ts, and pools.ts. The path and the mock.module → dynamic import() ordering are both valid.

However, there is a known Bun behavior: mock.module also mocks the functions in other test files where they aren't wanted to be mocked, and this is the case even when the function is defined inside the test block. If additional test files are added later that import (directly or transitively) from lib/services.js, this mock may interfere with them.

Consider moving the mock.module call to a --preload file or calling mock.restore() at the end of the test to limit scope.

🛡️ Suggested cleanup to prevent leakage
+import { describe, expect, test, mock, afterEach } from "bun:test";
-import { describe, expect, test, mock } from "bun:test";

 describe("Push.pushTables programmatic mode", () => {
+  afterEach(() => {
+    mock.restore();
+  });
+
   test("uses injected project client for table columns and indexes", async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/push.programmatic.tables.test.ts` around lines 11 - 63, The mock.module
call inside the test body (mock.module("../lib/services.js")) can leak to other
tests; either move this mocking into a test-wide preload file (run with
--preload) so it’s scoped before imports, or restore the mock at the end of the
test by calling mock.restore() after the dynamic import and assertions; update
the current test that references mock.module and the dynamic import of Push to
ensure mock.restore() is invoked (or relocate the mock setup to the preload) so
lib/services.js is not left mocked for other tests.

const push = new Push(projectClient, consoleClient, true);

const result = await push.pushTables(
[
{
$id: "tbl_1",
databaseId: "db_1",
name: "Regression Table",
rowSecurity: true,
enabled: true,
columns: [
{
key: "title",
type: "string",
size: 255,
required: true,
default: null,
array: false,
encrypt: false,
},
],
indexes: [
{
key: "idx_title",
type: "key",
columns: ["title"],
orders: ["ASC"],
},
],
},
],
{ attempts: 1, skipConfirmation: true },
);

expect(result.errors).toHaveLength(0);
expect(result.successfullyPushed).toBe(1);
expect(databaseServiceClientArgs.length).toBeGreaterThan(0);
expect(
databaseServiceClientArgs.every((sdk) => sdk === projectClient),
).toBe(true);
expect(tableServiceClientArgs.length).toBeGreaterThan(0);
expect(tableServiceClientArgs.every((sdk) => sdk === projectClient)).toBe(
true,
);
});
});