appwrite-utils-cli 1.6.5 → 1.6.6

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,6 +3,8 @@ import { Databases, IndexType, Query } from "node-appwrite";
3
3
  import { delay, tryAwaitWithRetry, calculateExponentialBackoff } from "../utils/helperFunctions.js";
4
4
  import { isLegacyDatabases } from "../utils/typeGuards.js";
5
5
  import { MessageFormatter } from "../shared/messageFormatter.js";
6
+ // System attributes that are always available for indexing in Appwrite
7
+ const SYSTEM_ATTRIBUTES = ['$id', '$createdAt', '$updatedAt', '$permissions'];
6
8
  /**
7
9
  * Wait for index to become available, with retry logic for stuck indexes and exponential backoff
8
10
  */
@@ -72,10 +74,12 @@ export const createOrUpdateIndexWithStatusCheck = async (dbId, db, collectionId,
72
74
  // First, validate that all required attributes exist
73
75
  const freshCollection = await db.getCollection(dbId, collectionId);
74
76
  const existingAttributeKeys = freshCollection.attributes.map((attr) => attr.key);
75
- const missingAttributes = index.attributes.filter(attr => !existingAttributeKeys.includes(attr));
77
+ // Include system attributes that are always available
78
+ const allAvailableAttributes = [...existingAttributeKeys, ...SYSTEM_ATTRIBUTES];
79
+ const missingAttributes = index.attributes.filter(attr => !allAvailableAttributes.includes(attr));
76
80
  if (missingAttributes.length > 0) {
77
81
  MessageFormatter.error(`Index '${index.key}' cannot be created: missing attributes [${missingAttributes.join(', ')}] (type: ${index.type})`);
78
- MessageFormatter.error(`Available attributes: [${existingAttributeKeys.join(', ')}]`);
82
+ MessageFormatter.error(`Available attributes: [${existingAttributeKeys.join(', ')}, ${SYSTEM_ATTRIBUTES.join(', ')}]`);
79
83
  return false; // Don't retry if attributes are missing
80
84
  }
81
85
  // Try to create/update the index using existing logic
@@ -169,12 +173,30 @@ export const createOrUpdateIndex = async (dbId, db, collectionId, index) => {
169
173
  // No existing index, create it
170
174
  createIndex = true;
171
175
  }
172
- else if (!existingIndex.indexes.some((existingIndex) => (existingIndex.key === index.key &&
173
- existingIndex.type === index.type &&
174
- existingIndex.attributes === index.attributes))) {
175
- // Existing index doesn't match, delete and recreate
176
- await db.deleteIndex(dbId, collectionId, existingIndex.indexes[0].key);
177
- createIndex = true;
176
+ else {
177
+ const existing = existingIndex.indexes[0];
178
+ // Check key and type
179
+ const keyMatches = existing.key === index.key;
180
+ const typeMatches = existing.type === index.type;
181
+ // Compare attributes as SETS (order doesn't matter, only content)
182
+ const existingAttrsSet = new Set(existing.attributes);
183
+ const newAttrsSet = new Set(index.attributes);
184
+ const attributesMatch = existingAttrsSet.size === newAttrsSet.size &&
185
+ [...existingAttrsSet].every(attr => newAttrsSet.has(attr));
186
+ // Compare orders as SETS if both exist (order doesn't matter)
187
+ let ordersMatch = true;
188
+ if (index.orders && existing.orders) {
189
+ const existingOrdersSet = new Set(existing.orders);
190
+ const newOrdersSet = new Set(index.orders);
191
+ ordersMatch =
192
+ existingOrdersSet.size === newOrdersSet.size &&
193
+ [...existingOrdersSet].every(ord => newOrdersSet.has(ord));
194
+ }
195
+ // Only recreate if something genuinely changed
196
+ if (!keyMatches || !typeMatches || !attributesMatch || !ordersMatch) {
197
+ await db.deleteIndex(dbId, collectionId, existing.key);
198
+ createIndex = true;
199
+ }
178
200
  }
179
201
  if (createIndex) {
180
202
  // Ensure orders array exists and matches attributes length
@@ -224,10 +224,14 @@ export const createOrUpdateCollections = async (database, databaseId, config, de
224
224
  attributes);
225
225
  // Add delay after creating attributes
226
226
  await delay(250);
227
- // For PUSH operations, only use indexes from local config (not remote)
228
- const indexesToUse = indexes || [];
227
+ // ALWAYS use indexes from local config, NEVER from server
228
+ const localCollectionConfig = config.collections?.find(c => c.name === collectionData.name || c.$id === collectionData.$id);
229
+ const indexesToUse = localCollectionConfig?.indexes || [];
229
230
  MessageFormatter.progress("Creating Indexes", { prefix: "Collections" });
230
231
  await createOrUpdateIndexesWithStatusCheck(databaseId, database, collectionToUse.$id, collectionToUse, indexesToUse);
232
+ // Delete indexes that exist on server but not in local config
233
+ const { deleteObsoleteIndexes } = await import('../shared/indexManager.js');
234
+ await deleteObsoleteIndexes(database, databaseId, collectionToUse, { indexes: indexesToUse }, { verbose: true });
231
235
  // Mark this collection as fully processed to prevent re-processing
232
236
  markCollectionProcessed(collectionToUse.$id, collectionData.name);
233
237
  // Add delay after creating indexes
@@ -424,8 +428,9 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
424
428
  }
425
429
  }
426
430
  }
427
- // Indexes
428
- const idxs = (indexes || []);
431
+ // ALWAYS use indexes from local config, NEVER from server (TablesDB path)
432
+ const localTableConfig = config.collections?.find(c => c.name === collectionData.name || c.$id === collectionData.$id);
433
+ const idxs = (localTableConfig?.indexes || []);
429
434
  for (const idx of idxs) {
430
435
  try {
431
436
  await adapter.createIndex({
@@ -1 +1,58 @@
1
+ import { type ApiMode } from "./utils/versionDetection.js";
2
+ /**
3
+ * Terminology configuration for API mode-specific naming
4
+ */
5
+ interface TerminologyConfig {
6
+ container: "table" | "collection";
7
+ containerName: "Table" | "Collection";
8
+ fields: "columns" | "attributes";
9
+ fieldName: "Column" | "Attribute";
10
+ security: "rowSecurity" | "documentSecurity";
11
+ schemaRef: "table.schema.json" | "collection.schema.json";
12
+ items: "rows" | "documents";
13
+ }
14
+ /**
15
+ * Detection result with source information
16
+ */
17
+ interface ApiModeDetectionResult {
18
+ apiMode: ApiMode;
19
+ useTables: boolean;
20
+ detectionSource: "appwrite.json" | "server-version" | "default";
21
+ serverVersion?: string;
22
+ }
23
+ /**
24
+ * Get terminology configuration based on API mode
25
+ */
26
+ export declare function getTerminologyConfig(useTables: boolean): TerminologyConfig;
27
+ /**
28
+ * Detect API mode using multiple detection sources
29
+ * Priority: appwrite.json > server version > default (collections)
30
+ */
31
+ export declare function detectApiMode(basePath: string): Promise<ApiModeDetectionResult>;
32
+ /**
33
+ * Create directory structure for Appwrite project
34
+ */
35
+ export declare function createProjectDirectories(basePath: string, useTables: boolean): {
36
+ appwriteFolder: string;
37
+ containerFolder: string;
38
+ schemaFolder: string;
39
+ yamlSchemaFolder: string;
40
+ dataFolder: string;
41
+ };
42
+ /**
43
+ * Create example YAML schema file with correct terminology
44
+ */
45
+ export declare function createExampleSchema(containerFolder: string, terminology: TerminologyConfig): string;
46
+ /**
47
+ * Create JSON schema for YAML validation
48
+ */
49
+ export declare function createYamlValidationSchema(yamlSchemaFolder: string, useTables: boolean): string;
50
+ /**
51
+ * Initialize a new Appwrite project with correct directory structure and terminology
52
+ */
53
+ export declare function initProject(basePath?: string, forceApiMode?: 'legacy' | 'tablesdb'): Promise<void>;
54
+ /**
55
+ * Create a new collection or table schema file
56
+ */
57
+ export declare function createSchema(name: string, basePath?: string, forceApiMode?: 'legacy' | 'tablesdb'): Promise<void>;
1
58
  export {};
@@ -1 +1,484 @@
1
- export {};
1
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ulid } from "ulidx";
4
+ import { MessageFormatter } from "./shared/messageFormatter.js";
5
+ import { detectAppwriteVersionCached, fetchServerVersion, isVersionAtLeast } from "./utils/versionDetection.js";
6
+ import { loadAppwriteProjectConfig, findAppwriteProjectConfig, isTablesDBProject } from "./utils/projectConfig.js";
7
+ import { findYamlConfig, generateYamlConfigTemplate } from "./config/yamlConfig.js";
8
+ import { loadYamlConfig } from "./config/yamlConfig.js";
9
+ import { hasSessionAuth } from "./utils/sessionAuth.js";
10
+ /**
11
+ * Get terminology configuration based on API mode
12
+ */
13
+ export function getTerminologyConfig(useTables) {
14
+ return useTables
15
+ ? {
16
+ container: "table",
17
+ containerName: "Table",
18
+ fields: "columns",
19
+ fieldName: "Column",
20
+ security: "rowSecurity",
21
+ schemaRef: "table.schema.json",
22
+ items: "rows"
23
+ }
24
+ : {
25
+ container: "collection",
26
+ containerName: "Collection",
27
+ fields: "attributes",
28
+ fieldName: "Attribute",
29
+ security: "documentSecurity",
30
+ schemaRef: "collection.schema.json",
31
+ items: "documents"
32
+ };
33
+ }
34
+ /**
35
+ * Detect API mode using multiple detection sources
36
+ * Priority: appwrite.json > server version > default (collections)
37
+ */
38
+ export async function detectApiMode(basePath) {
39
+ let useTables = false;
40
+ let detectionSource = "default";
41
+ let serverVersion;
42
+ try {
43
+ // Priority 1: Check for existing appwrite.json project config
44
+ const projectConfigPath = findAppwriteProjectConfig(basePath);
45
+ if (projectConfigPath) {
46
+ const projectConfig = loadAppwriteProjectConfig(projectConfigPath);
47
+ if (projectConfig) {
48
+ useTables = isTablesDBProject(projectConfig);
49
+ detectionSource = "appwrite.json";
50
+ MessageFormatter.info(`Detected ${useTables ? 'TablesDB' : 'Collections'} project from ${projectConfigPath}`, { prefix: "Setup" });
51
+ return {
52
+ apiMode: useTables ? 'tablesdb' : 'legacy',
53
+ useTables,
54
+ detectionSource
55
+ };
56
+ }
57
+ }
58
+ // Priority 2: Try reading existing YAML config for version detection
59
+ const yamlPath = findYamlConfig(basePath);
60
+ if (yamlPath) {
61
+ const cfg = await loadYamlConfig(yamlPath);
62
+ if (cfg) {
63
+ const endpoint = cfg.appwriteEndpoint;
64
+ const projectId = cfg.appwriteProject;
65
+ if (hasSessionAuth(endpoint, projectId)) {
66
+ MessageFormatter.info("Using session authentication for version detection", { prefix: "Setup" });
67
+ }
68
+ const ver = await fetchServerVersion(endpoint);
69
+ serverVersion = ver || undefined;
70
+ if (isVersionAtLeast(ver || undefined, '1.8.0')) {
71
+ useTables = true;
72
+ detectionSource = "server-version";
73
+ MessageFormatter.info(`Detected TablesDB support (Appwrite ${ver})`, { prefix: "Setup" });
74
+ }
75
+ else {
76
+ MessageFormatter.info(`Using Collections API (Appwrite ${ver || 'unknown'})`, { prefix: "Setup" });
77
+ }
78
+ return {
79
+ apiMode: useTables ? 'tablesdb' : 'legacy',
80
+ useTables,
81
+ detectionSource,
82
+ serverVersion
83
+ };
84
+ }
85
+ }
86
+ }
87
+ catch (error) {
88
+ MessageFormatter.warning(`Version detection failed, defaulting to Collections API: ${error instanceof Error ? error.message : String(error)}`, { prefix: "Setup" });
89
+ }
90
+ // Default to Collections API
91
+ return {
92
+ apiMode: 'legacy',
93
+ useTables: false,
94
+ detectionSource: 'default'
95
+ };
96
+ }
97
+ /**
98
+ * Create directory structure for Appwrite project
99
+ */
100
+ export function createProjectDirectories(basePath, useTables) {
101
+ const appwriteFolder = path.join(basePath, ".appwrite");
102
+ const containerName = useTables ? "tables" : "collections";
103
+ const containerFolder = path.join(appwriteFolder, containerName);
104
+ const schemaFolder = path.join(appwriteFolder, "schemas");
105
+ const yamlSchemaFolder = path.join(appwriteFolder, ".yaml_schemas");
106
+ const dataFolder = path.join(appwriteFolder, "importData");
107
+ // Create all directories
108
+ for (const dir of [appwriteFolder, containerFolder, schemaFolder, yamlSchemaFolder, dataFolder]) {
109
+ if (!existsSync(dir)) {
110
+ mkdirSync(dir, { recursive: true });
111
+ }
112
+ }
113
+ return {
114
+ appwriteFolder,
115
+ containerFolder,
116
+ schemaFolder,
117
+ yamlSchemaFolder,
118
+ dataFolder
119
+ };
120
+ }
121
+ /**
122
+ * Create example YAML schema file with correct terminology
123
+ */
124
+ export function createExampleSchema(containerFolder, terminology) {
125
+ const yamlExample = `# yaml-language-server: $schema=../.yaml_schemas/${terminology.schemaRef}
126
+ # Example ${terminology.containerName} Definition
127
+ name: Example${terminology.containerName}
128
+ id: example_${terminology.container}_${Date.now()}
129
+ ${terminology.security}: false
130
+ enabled: true
131
+ permissions:
132
+ - permission: read
133
+ target: any
134
+ - permission: create
135
+ target: users
136
+ - permission: update
137
+ target: users
138
+ - permission: delete
139
+ target: users
140
+ ${terminology.fields}:
141
+ - key: title
142
+ type: string
143
+ size: 255
144
+ required: true
145
+ description: "The title of the item"
146
+ - key: description
147
+ type: string
148
+ size: 1000
149
+ required: false
150
+ description: "A longer description"
151
+ - key: isActive
152
+ type: boolean
153
+ required: false
154
+ default: true${terminology.container === 'table' ? `
155
+ - key: uniqueCode
156
+ type: string
157
+ size: 50
158
+ required: false
159
+ unique: true
160
+ description: "Unique identifier code (TablesDB feature)"` : ''}
161
+ indexes:
162
+ - key: title_search
163
+ type: fulltext
164
+ attributes:
165
+ - title
166
+ importDefs: []
167
+ `;
168
+ const examplePath = path.join(containerFolder, `Example${terminology.containerName}.yaml`);
169
+ writeFileSync(examplePath, yamlExample);
170
+ MessageFormatter.info(`Created example ${terminology.container} definition with ${terminology.fields} terminology`, { prefix: "Setup" });
171
+ return examplePath;
172
+ }
173
+ /**
174
+ * Create JSON schema for YAML validation
175
+ */
176
+ export function createYamlValidationSchema(yamlSchemaFolder, useTables) {
177
+ const schemaFileName = useTables ? "table.schema.json" : "collection.schema.json";
178
+ const containerType = useTables ? "Table" : "Collection";
179
+ const fieldsName = useTables ? "columns" : "attributes";
180
+ const fieldsDescription = useTables ? "Table columns (fields)" : "Collection attributes (fields)";
181
+ const securityField = useTables ? "rowSecurity" : "documentSecurity";
182
+ const securityDescription = useTables ? "Enable row-level permissions" : "Enable document-level permissions";
183
+ const itemType = useTables ? "row" : "document";
184
+ const schema = {
185
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
186
+ "$id": `https://appwrite-utils.dev/schemas/${schemaFileName}`,
187
+ "title": `Appwrite ${containerType} Definition`,
188
+ "description": `Schema for defining Appwrite ${useTables ? 'tables' : 'collections'} in YAML${useTables ? ' (TablesDB API)' : ''}`,
189
+ "type": "object",
190
+ "properties": {
191
+ "name": {
192
+ "type": "string",
193
+ "description": `The name of the ${useTables ? 'table' : 'collection'}`
194
+ },
195
+ "id": {
196
+ "type": "string",
197
+ "description": `The ID of the ${useTables ? 'table' : 'collection'} (optional, auto-generated if not provided)`,
198
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,35}$"
199
+ },
200
+ [securityField]: {
201
+ "type": "boolean",
202
+ "default": false,
203
+ "description": securityDescription
204
+ },
205
+ "enabled": {
206
+ "type": "boolean",
207
+ "default": true,
208
+ "description": `Whether the ${useTables ? 'table' : 'collection'} is enabled`
209
+ },
210
+ "permissions": {
211
+ "type": "array",
212
+ "description": `${containerType}-level permissions`,
213
+ "items": {
214
+ "type": "object",
215
+ "properties": {
216
+ "permission": {
217
+ "type": "string",
218
+ "enum": ["read", "create", "update", "delete"],
219
+ "description": "The permission type"
220
+ },
221
+ "target": {
222
+ "type": "string",
223
+ "description": "Permission target (e.g., 'any', 'users', 'users/verified', 'label:admin')"
224
+ }
225
+ },
226
+ "required": ["permission", "target"],
227
+ "additionalProperties": false
228
+ }
229
+ },
230
+ [fieldsName]: {
231
+ "type": "array",
232
+ "description": fieldsDescription,
233
+ "items": {
234
+ "type": "object",
235
+ "properties": {
236
+ "key": {
237
+ "type": "string",
238
+ "description": `${useTables ? 'Column' : 'Attribute'} name`,
239
+ "pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
240
+ },
241
+ "type": {
242
+ "type": "string",
243
+ "enum": ["string", "integer", "double", "boolean", "datetime", "email", "ip", "url", "enum", "relationship"],
244
+ "description": `${useTables ? 'Column' : 'Attribute'} data type`
245
+ },
246
+ "size": {
247
+ "type": "number",
248
+ "description": `Maximum size for string ${useTables ? 'columns' : 'attributes'}`,
249
+ "minimum": 1,
250
+ "maximum": 1073741824
251
+ },
252
+ "required": {
253
+ "type": "boolean",
254
+ "default": false,
255
+ "description": `Whether the ${useTables ? 'column' : 'attribute'} is required`
256
+ },
257
+ "array": {
258
+ "type": "boolean",
259
+ "default": false,
260
+ "description": `Whether the ${useTables ? 'column' : 'attribute'} is an array`
261
+ },
262
+ ...(useTables ? {
263
+ "unique": {
264
+ "type": "boolean",
265
+ "default": false,
266
+ "description": "Whether the column values must be unique (TablesDB feature)"
267
+ }
268
+ } : {}),
269
+ "default": {
270
+ "description": `Default value for the ${useTables ? 'column' : 'attribute'}`
271
+ },
272
+ "description": {
273
+ "type": "string",
274
+ "description": `${useTables ? 'Column' : 'Attribute'} description`
275
+ },
276
+ "min": {
277
+ "type": "number",
278
+ "description": `Minimum value for numeric ${useTables ? 'columns' : 'attributes'}`
279
+ },
280
+ "max": {
281
+ "type": "number",
282
+ "description": `Maximum value for numeric ${useTables ? 'columns' : 'attributes'}`
283
+ },
284
+ "elements": {
285
+ "type": "array",
286
+ "items": {
287
+ "type": "string"
288
+ },
289
+ "description": `Allowed values for enum ${useTables ? 'columns' : 'attributes'}`
290
+ },
291
+ "relatedCollection": {
292
+ "type": "string",
293
+ "description": `Related ${useTables ? 'table' : 'collection'} name for relationship ${useTables ? 'columns' : 'attributes'}`
294
+ },
295
+ "relationType": {
296
+ "type": "string",
297
+ "enum": ["oneToOne", "oneToMany", "manyToOne", "manyToMany"],
298
+ "description": "Type of relationship"
299
+ },
300
+ "twoWay": {
301
+ "type": "boolean",
302
+ "description": "Whether the relationship is bidirectional"
303
+ },
304
+ "twoWayKey": {
305
+ "type": "string",
306
+ "description": "Key name for the reverse relationship"
307
+ },
308
+ "onDelete": {
309
+ "type": "string",
310
+ "enum": ["cascade", "restrict", "setNull"],
311
+ "description": `Action to take when related ${itemType} is deleted`
312
+ },
313
+ "side": {
314
+ "type": "string",
315
+ "enum": ["parent", "child"],
316
+ "description": "Side of the relationship"
317
+ }
318
+ },
319
+ "required": ["key", "type"],
320
+ "additionalProperties": false,
321
+ "allOf": [
322
+ {
323
+ "if": {
324
+ "properties": { "type": { "const": "enum" } }
325
+ },
326
+ "then": {
327
+ "required": ["elements"]
328
+ }
329
+ },
330
+ {
331
+ "if": {
332
+ "properties": { "type": { "const": "relationship" } }
333
+ },
334
+ "then": {
335
+ "required": ["relatedCollection", "relationType"]
336
+ }
337
+ }
338
+ ]
339
+ }
340
+ },
341
+ "indexes": {
342
+ "type": "array",
343
+ "description": `Database indexes for the ${useTables ? 'table' : 'collection'}`,
344
+ "items": {
345
+ "type": "object",
346
+ "properties": {
347
+ "key": {
348
+ "type": "string",
349
+ "description": "Index name"
350
+ },
351
+ "type": {
352
+ "type": "string",
353
+ "enum": ["key", "fulltext", "unique"],
354
+ "description": "Index type"
355
+ },
356
+ "attributes": {
357
+ "type": "array",
358
+ "items": {
359
+ "type": "string"
360
+ },
361
+ "description": `${useTables ? 'Columns' : 'Attributes'} to index`,
362
+ "minItems": 1
363
+ },
364
+ "orders": {
365
+ "type": "array",
366
+ "items": {
367
+ "type": "string",
368
+ "enum": ["ASC", "DESC"]
369
+ },
370
+ "description": `Sort order for each ${useTables ? 'column' : 'attribute'}`
371
+ }
372
+ },
373
+ "required": ["key", "type", "attributes"],
374
+ "additionalProperties": false
375
+ }
376
+ },
377
+ "importDefs": {
378
+ "type": "array",
379
+ "description": "Import definitions for data migration",
380
+ "default": []
381
+ }
382
+ },
383
+ "required": ["name"],
384
+ "additionalProperties": false
385
+ };
386
+ const schemaPath = path.join(yamlSchemaFolder, schemaFileName);
387
+ writeFileSync(schemaPath, JSON.stringify(schema, null, 2));
388
+ return schemaPath;
389
+ }
390
+ /**
391
+ * Initialize a new Appwrite project with correct directory structure and terminology
392
+ */
393
+ export async function initProject(basePath, forceApiMode) {
394
+ const projectPath = basePath || process.cwd();
395
+ // Detect API mode
396
+ const detection = forceApiMode
397
+ ? {
398
+ apiMode: forceApiMode,
399
+ useTables: forceApiMode === 'tablesdb',
400
+ detectionSource: 'forced',
401
+ }
402
+ : await detectApiMode(projectPath);
403
+ const { useTables, detectionSource } = detection;
404
+ const terminology = getTerminologyConfig(useTables);
405
+ // Create directory structure
406
+ const dirs = createProjectDirectories(projectPath, useTables);
407
+ // Generate YAML config
408
+ const configPath = path.join(dirs.appwriteFolder, "config.yaml");
409
+ generateYamlConfigTemplate(configPath);
410
+ // Create example schema file
411
+ createExampleSchema(dirs.containerFolder, terminology);
412
+ // Create JSON validation schema
413
+ createYamlValidationSchema(dirs.yamlSchemaFolder, useTables);
414
+ // Success messages
415
+ const containerType = useTables ? "TablesDB" : "Collections";
416
+ MessageFormatter.success(`Created YAML config and setup files/directories in .appwrite/ folder.`, { prefix: "Setup" });
417
+ MessageFormatter.info(`Project configured for ${containerType} API (${detectionSource} detection)`, { prefix: "Setup" });
418
+ MessageFormatter.info("You can now configure your project in .appwrite/config.yaml", { prefix: "Setup" });
419
+ MessageFormatter.info(`${terminology.containerName}s can be defined in .appwrite/${terminology.container}s/ as .yaml files`, { prefix: "Setup" });
420
+ MessageFormatter.info("Schemas will be generated in .appwrite/schemas/", { prefix: "Setup" });
421
+ MessageFormatter.info("Import data can be placed in .appwrite/importData/", { prefix: "Setup" });
422
+ if (useTables) {
423
+ MessageFormatter.info("TablesDB features: unique constraints, enhanced performance, row-level security", { prefix: "Setup" });
424
+ }
425
+ }
426
+ /**
427
+ * Create a new collection or table schema file
428
+ */
429
+ export async function createSchema(name, basePath, forceApiMode) {
430
+ const projectPath = basePath || process.cwd();
431
+ // Detect API mode
432
+ const detection = forceApiMode
433
+ ? {
434
+ apiMode: forceApiMode,
435
+ useTables: forceApiMode === 'tablesdb',
436
+ detectionSource: 'forced',
437
+ }
438
+ : await detectApiMode(projectPath);
439
+ const { useTables } = detection;
440
+ const terminology = getTerminologyConfig(useTables);
441
+ // Find or create container directory
442
+ const appwriteFolder = path.join(projectPath, ".appwrite");
443
+ const containerFolder = path.join(appwriteFolder, useTables ? "tables" : "collections");
444
+ if (!existsSync(containerFolder)) {
445
+ mkdirSync(containerFolder, { recursive: true });
446
+ }
447
+ // Create YAML schema file
448
+ const yamlSchema = `# yaml-language-server: $schema=../.yaml_schemas/${terminology.schemaRef}
449
+ # ${terminology.containerName} Definition: ${name}
450
+ name: ${name}
451
+ id: ${ulid()}
452
+ ${terminology.security}: false
453
+ enabled: true
454
+ permissions:
455
+ - permission: read
456
+ target: any
457
+ - permission: create
458
+ target: users
459
+ - permission: update
460
+ target: users
461
+ - permission: delete
462
+ target: users
463
+ ${terminology.fields}:
464
+ # Add your ${terminology.fields} here
465
+ # Example:
466
+ # - key: title
467
+ # type: string
468
+ # size: 255
469
+ # required: true
470
+ # description: "The title of the item"
471
+ indexes:
472
+ # Add your indexes here
473
+ # Example:
474
+ # - key: title_search
475
+ # type: fulltext
476
+ # attributes:
477
+ # - title
478
+ importDefs: []
479
+ `;
480
+ const schemaPath = path.join(containerFolder, `${name}.yaml`);
481
+ writeFileSync(schemaPath, yamlSchema);
482
+ MessageFormatter.success(`Created ${terminology.container} schema: ${schemaPath}`, { prefix: "Setup" });
483
+ MessageFormatter.info(`Add your ${terminology.fields} to define the ${terminology.container} structure`, { prefix: "Setup" });
484
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrite-utils-cli",
3
3
  "description": "Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.",
4
- "version": "1.6.5",
4
+ "version": "1.6.6",
5
5
  "main": "src/main.ts",
6
6
  "type": "module",
7
7
  "repository": {
@@ -5,6 +5,9 @@ import { delay, tryAwaitWithRetry, calculateExponentialBackoff } from "../utils/
5
5
  import { isLegacyDatabases } from "../utils/typeGuards.js";
6
6
  import { MessageFormatter } from "../shared/messageFormatter.js";
7
7
 
8
+ // System attributes that are always available for indexing in Appwrite
9
+ const SYSTEM_ATTRIBUTES = ['$id', '$createdAt', '$updatedAt', '$permissions'];
10
+
8
11
  // Interface for index with status
9
12
  interface IndexWithStatus {
10
13
  key: string;
@@ -116,12 +119,15 @@ export const createOrUpdateIndexWithStatusCheck = async (
116
119
  // First, validate that all required attributes exist
117
120
  const freshCollection = await db.getCollection(dbId, collectionId);
118
121
  const existingAttributeKeys = freshCollection.attributes.map((attr: any) => attr.key);
119
-
120
- const missingAttributes = index.attributes.filter(attr => !existingAttributeKeys.includes(attr));
122
+
123
+ // Include system attributes that are always available
124
+ const allAvailableAttributes = [...existingAttributeKeys, ...SYSTEM_ATTRIBUTES];
125
+
126
+ const missingAttributes = index.attributes.filter(attr => !allAvailableAttributes.includes(attr));
121
127
 
122
128
  if (missingAttributes.length > 0) {
123
129
  MessageFormatter.error(`Index '${index.key}' cannot be created: missing attributes [${missingAttributes.join(', ')}] (type: ${index.type})`);
124
- MessageFormatter.error(`Available attributes: [${existingAttributeKeys.join(', ')}]`);
130
+ MessageFormatter.error(`Available attributes: [${existingAttributeKeys.join(', ')}, ${SYSTEM_ATTRIBUTES.join(', ')}]`);
125
131
  return false; // Don't retry if attributes are missing
126
132
  }
127
133
 
@@ -279,17 +285,35 @@ export const createOrUpdateIndex = async (
279
285
  if (existingIndex.total === 0) {
280
286
  // No existing index, create it
281
287
  createIndex = true;
282
- } else if (
283
- !existingIndex.indexes.some(
284
- (existingIndex) =>
285
- (existingIndex.key === index.key &&
286
- existingIndex.type === index.type &&
287
- existingIndex.attributes === index.attributes)
288
- )
289
- ) {
290
- // Existing index doesn't match, delete and recreate
291
- await db.deleteIndex(dbId, collectionId, existingIndex.indexes[0].key);
292
- createIndex = true;
288
+ } else {
289
+ const existing = existingIndex.indexes[0];
290
+
291
+ // Check key and type
292
+ const keyMatches = existing.key === index.key;
293
+ const typeMatches = existing.type === index.type;
294
+
295
+ // Compare attributes as SETS (order doesn't matter, only content)
296
+ const existingAttrsSet = new Set(existing.attributes);
297
+ const newAttrsSet = new Set(index.attributes);
298
+ const attributesMatch =
299
+ existingAttrsSet.size === newAttrsSet.size &&
300
+ [...existingAttrsSet].every(attr => newAttrsSet.has(attr));
301
+
302
+ // Compare orders as SETS if both exist (order doesn't matter)
303
+ let ordersMatch = true;
304
+ if (index.orders && existing.orders) {
305
+ const existingOrdersSet = new Set(existing.orders);
306
+ const newOrdersSet = new Set(index.orders);
307
+ ordersMatch =
308
+ existingOrdersSet.size === newOrdersSet.size &&
309
+ [...existingOrdersSet].every(ord => newOrdersSet.has(ord));
310
+ }
311
+
312
+ // Only recreate if something genuinely changed
313
+ if (!keyMatches || !typeMatches || !attributesMatch || !ordersMatch) {
314
+ await db.deleteIndex(dbId, collectionId, existing.key);
315
+ createIndex = true;
316
+ }
293
317
  }
294
318
 
295
319
  if (createIndex) {
@@ -339,8 +339,11 @@ export const createOrUpdateCollections = async (
339
339
  // Add delay after creating attributes
340
340
  await delay(250);
341
341
 
342
- // For PUSH operations, only use indexes from local config (not remote)
343
- const indexesToUse = indexes || [];
342
+ // ALWAYS use indexes from local config, NEVER from server
343
+ const localCollectionConfig = config.collections?.find(
344
+ c => c.name === collectionData.name || c.$id === collectionData.$id
345
+ );
346
+ const indexesToUse = localCollectionConfig?.indexes || [];
344
347
 
345
348
  MessageFormatter.progress("Creating Indexes", { prefix: "Collections" });
346
349
  await createOrUpdateIndexesWithStatusCheck(
@@ -351,6 +354,16 @@ export const createOrUpdateCollections = async (
351
354
  indexesToUse as Indexes
352
355
  );
353
356
 
357
+ // Delete indexes that exist on server but not in local config
358
+ const { deleteObsoleteIndexes } = await import('../shared/indexManager.js');
359
+ await deleteObsoleteIndexes(
360
+ database,
361
+ databaseId,
362
+ collectionToUse!,
363
+ { indexes: indexesToUse } as any,
364
+ { verbose: true }
365
+ );
366
+
354
367
  // Mark this collection as fully processed to prevent re-processing
355
368
  markCollectionProcessed(collectionToUse!.$id, collectionData.name);
356
369
 
@@ -562,8 +575,11 @@ export const createOrUpdateCollectionsViaAdapter = async (
562
575
  }
563
576
  }
564
577
 
565
- // Indexes
566
- const idxs = (indexes || []) as any[];
578
+ // ALWAYS use indexes from local config, NEVER from server (TablesDB path)
579
+ const localTableConfig = config.collections?.find(
580
+ c => c.name === collectionData.name || c.$id === collectionData.$id
581
+ );
582
+ const idxs = (localTableConfig?.indexes || []) as any[];
567
583
  for (const idx of idxs) {
568
584
  try {
569
585
  await adapter.createIndex({
@@ -0,0 +1,597 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ulid } from "ulidx";
4
+ import { MessageFormatter } from "./shared/messageFormatter.js";
5
+ import {
6
+ detectAppwriteVersionCached,
7
+ fetchServerVersion,
8
+ isVersionAtLeast,
9
+ type ApiMode
10
+ } from "./utils/versionDetection.js";
11
+ import {
12
+ loadAppwriteProjectConfig,
13
+ findAppwriteProjectConfig,
14
+ isTablesDBProject
15
+ } from "./utils/projectConfig.js";
16
+ import { findYamlConfig, generateYamlConfigTemplate } from "./config/yamlConfig.js";
17
+ import { loadYamlConfig } from "./config/yamlConfig.js";
18
+ import { hasSessionAuth } from "./utils/sessionAuth.js";
19
+
20
+ /**
21
+ * Terminology configuration for API mode-specific naming
22
+ */
23
+ interface TerminologyConfig {
24
+ container: "table" | "collection";
25
+ containerName: "Table" | "Collection";
26
+ fields: "columns" | "attributes";
27
+ fieldName: "Column" | "Attribute";
28
+ security: "rowSecurity" | "documentSecurity";
29
+ schemaRef: "table.schema.json" | "collection.schema.json";
30
+ items: "rows" | "documents";
31
+ }
32
+
33
+ /**
34
+ * Detection result with source information
35
+ */
36
+ interface ApiModeDetectionResult {
37
+ apiMode: ApiMode;
38
+ useTables: boolean;
39
+ detectionSource: "appwrite.json" | "server-version" | "default";
40
+ serverVersion?: string;
41
+ }
42
+
43
+ /**
44
+ * Get terminology configuration based on API mode
45
+ */
46
+ export function getTerminologyConfig(useTables: boolean): TerminologyConfig {
47
+ return useTables
48
+ ? {
49
+ container: "table",
50
+ containerName: "Table",
51
+ fields: "columns",
52
+ fieldName: "Column",
53
+ security: "rowSecurity",
54
+ schemaRef: "table.schema.json",
55
+ items: "rows"
56
+ }
57
+ : {
58
+ container: "collection",
59
+ containerName: "Collection",
60
+ fields: "attributes",
61
+ fieldName: "Attribute",
62
+ security: "documentSecurity",
63
+ schemaRef: "collection.schema.json",
64
+ items: "documents"
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Detect API mode using multiple detection sources
70
+ * Priority: appwrite.json > server version > default (collections)
71
+ */
72
+ export async function detectApiMode(basePath: string): Promise<ApiModeDetectionResult> {
73
+ let useTables = false;
74
+ let detectionSource: "appwrite.json" | "server-version" | "default" = "default";
75
+ let serverVersion: string | undefined;
76
+
77
+ try {
78
+ // Priority 1: Check for existing appwrite.json project config
79
+ const projectConfigPath = findAppwriteProjectConfig(basePath);
80
+ if (projectConfigPath) {
81
+ const projectConfig = loadAppwriteProjectConfig(projectConfigPath);
82
+ if (projectConfig) {
83
+ useTables = isTablesDBProject(projectConfig);
84
+ detectionSource = "appwrite.json";
85
+ MessageFormatter.info(
86
+ `Detected ${useTables ? 'TablesDB' : 'Collections'} project from ${projectConfigPath}`,
87
+ { prefix: "Setup" }
88
+ );
89
+ return {
90
+ apiMode: useTables ? 'tablesdb' : 'legacy',
91
+ useTables,
92
+ detectionSource
93
+ };
94
+ }
95
+ }
96
+
97
+ // Priority 2: Try reading existing YAML config for version detection
98
+ const yamlPath = findYamlConfig(basePath);
99
+ if (yamlPath) {
100
+ const cfg = await loadYamlConfig(yamlPath);
101
+ if (cfg) {
102
+ const endpoint = cfg.appwriteEndpoint;
103
+ const projectId = cfg.appwriteProject;
104
+
105
+ if (hasSessionAuth(endpoint, projectId)) {
106
+ MessageFormatter.info("Using session authentication for version detection", { prefix: "Setup" });
107
+ }
108
+
109
+ const ver = await fetchServerVersion(endpoint);
110
+ serverVersion = ver || undefined;
111
+
112
+ if (isVersionAtLeast(ver || undefined, '1.8.0')) {
113
+ useTables = true;
114
+ detectionSource = "server-version";
115
+ MessageFormatter.info(`Detected TablesDB support (Appwrite ${ver})`, { prefix: "Setup" });
116
+ } else {
117
+ MessageFormatter.info(`Using Collections API (Appwrite ${ver || 'unknown'})`, { prefix: "Setup" });
118
+ }
119
+
120
+ return {
121
+ apiMode: useTables ? 'tablesdb' : 'legacy',
122
+ useTables,
123
+ detectionSource,
124
+ serverVersion
125
+ };
126
+ }
127
+ }
128
+ } catch (error) {
129
+ MessageFormatter.warning(
130
+ `Version detection failed, defaulting to Collections API: ${error instanceof Error ? error.message : String(error)}`,
131
+ { prefix: "Setup" }
132
+ );
133
+ }
134
+
135
+ // Default to Collections API
136
+ return {
137
+ apiMode: 'legacy',
138
+ useTables: false,
139
+ detectionSource: 'default'
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Create directory structure for Appwrite project
145
+ */
146
+ export function createProjectDirectories(basePath: string, useTables: boolean): {
147
+ appwriteFolder: string;
148
+ containerFolder: string;
149
+ schemaFolder: string;
150
+ yamlSchemaFolder: string;
151
+ dataFolder: string;
152
+ } {
153
+ const appwriteFolder = path.join(basePath, ".appwrite");
154
+ const containerName = useTables ? "tables" : "collections";
155
+ const containerFolder = path.join(appwriteFolder, containerName);
156
+ const schemaFolder = path.join(appwriteFolder, "schemas");
157
+ const yamlSchemaFolder = path.join(appwriteFolder, ".yaml_schemas");
158
+ const dataFolder = path.join(appwriteFolder, "importData");
159
+
160
+ // Create all directories
161
+ for (const dir of [appwriteFolder, containerFolder, schemaFolder, yamlSchemaFolder, dataFolder]) {
162
+ if (!existsSync(dir)) {
163
+ mkdirSync(dir, { recursive: true });
164
+ }
165
+ }
166
+
167
+ return {
168
+ appwriteFolder,
169
+ containerFolder,
170
+ schemaFolder,
171
+ yamlSchemaFolder,
172
+ dataFolder
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Create example YAML schema file with correct terminology
178
+ */
179
+ export function createExampleSchema(
180
+ containerFolder: string,
181
+ terminology: TerminologyConfig
182
+ ): string {
183
+ const yamlExample = `# yaml-language-server: $schema=../.yaml_schemas/${terminology.schemaRef}
184
+ # Example ${terminology.containerName} Definition
185
+ name: Example${terminology.containerName}
186
+ id: example_${terminology.container}_${Date.now()}
187
+ ${terminology.security}: false
188
+ enabled: true
189
+ permissions:
190
+ - permission: read
191
+ target: any
192
+ - permission: create
193
+ target: users
194
+ - permission: update
195
+ target: users
196
+ - permission: delete
197
+ target: users
198
+ ${terminology.fields}:
199
+ - key: title
200
+ type: string
201
+ size: 255
202
+ required: true
203
+ description: "The title of the item"
204
+ - key: description
205
+ type: string
206
+ size: 1000
207
+ required: false
208
+ description: "A longer description"
209
+ - key: isActive
210
+ type: boolean
211
+ required: false
212
+ default: true${terminology.container === 'table' ? `
213
+ - key: uniqueCode
214
+ type: string
215
+ size: 50
216
+ required: false
217
+ unique: true
218
+ description: "Unique identifier code (TablesDB feature)"` : ''}
219
+ indexes:
220
+ - key: title_search
221
+ type: fulltext
222
+ attributes:
223
+ - title
224
+ importDefs: []
225
+ `;
226
+
227
+ const examplePath = path.join(containerFolder, `Example${terminology.containerName}.yaml`);
228
+ writeFileSync(examplePath, yamlExample);
229
+
230
+ MessageFormatter.info(
231
+ `Created example ${terminology.container} definition with ${terminology.fields} terminology`,
232
+ { prefix: "Setup" }
233
+ );
234
+
235
+ return examplePath;
236
+ }
237
+
238
+ /**
239
+ * Create JSON schema for YAML validation
240
+ */
241
+ export function createYamlValidationSchema(
242
+ yamlSchemaFolder: string,
243
+ useTables: boolean
244
+ ): string {
245
+ const schemaFileName = useTables ? "table.schema.json" : "collection.schema.json";
246
+ const containerType = useTables ? "Table" : "Collection";
247
+ const fieldsName = useTables ? "columns" : "attributes";
248
+ const fieldsDescription = useTables ? "Table columns (fields)" : "Collection attributes (fields)";
249
+ const securityField = useTables ? "rowSecurity" : "documentSecurity";
250
+ const securityDescription = useTables ? "Enable row-level permissions" : "Enable document-level permissions";
251
+ const itemType = useTables ? "row" : "document";
252
+
253
+ const schema = {
254
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
255
+ "$id": `https://appwrite-utils.dev/schemas/${schemaFileName}`,
256
+ "title": `Appwrite ${containerType} Definition`,
257
+ "description": `Schema for defining Appwrite ${useTables ? 'tables' : 'collections'} in YAML${useTables ? ' (TablesDB API)' : ''}`,
258
+ "type": "object",
259
+ "properties": {
260
+ "name": {
261
+ "type": "string",
262
+ "description": `The name of the ${useTables ? 'table' : 'collection'}`
263
+ },
264
+ "id": {
265
+ "type": "string",
266
+ "description": `The ID of the ${useTables ? 'table' : 'collection'} (optional, auto-generated if not provided)`,
267
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,35}$"
268
+ },
269
+ [securityField]: {
270
+ "type": "boolean",
271
+ "default": false,
272
+ "description": securityDescription
273
+ },
274
+ "enabled": {
275
+ "type": "boolean",
276
+ "default": true,
277
+ "description": `Whether the ${useTables ? 'table' : 'collection'} is enabled`
278
+ },
279
+ "permissions": {
280
+ "type": "array",
281
+ "description": `${containerType}-level permissions`,
282
+ "items": {
283
+ "type": "object",
284
+ "properties": {
285
+ "permission": {
286
+ "type": "string",
287
+ "enum": ["read", "create", "update", "delete"],
288
+ "description": "The permission type"
289
+ },
290
+ "target": {
291
+ "type": "string",
292
+ "description": "Permission target (e.g., 'any', 'users', 'users/verified', 'label:admin')"
293
+ }
294
+ },
295
+ "required": ["permission", "target"],
296
+ "additionalProperties": false
297
+ }
298
+ },
299
+ [fieldsName]: {
300
+ "type": "array",
301
+ "description": fieldsDescription,
302
+ "items": {
303
+ "type": "object",
304
+ "properties": {
305
+ "key": {
306
+ "type": "string",
307
+ "description": `${useTables ? 'Column' : 'Attribute'} name`,
308
+ "pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
309
+ },
310
+ "type": {
311
+ "type": "string",
312
+ "enum": ["string", "integer", "double", "boolean", "datetime", "email", "ip", "url", "enum", "relationship"],
313
+ "description": `${useTables ? 'Column' : 'Attribute'} data type`
314
+ },
315
+ "size": {
316
+ "type": "number",
317
+ "description": `Maximum size for string ${useTables ? 'columns' : 'attributes'}`,
318
+ "minimum": 1,
319
+ "maximum": 1073741824
320
+ },
321
+ "required": {
322
+ "type": "boolean",
323
+ "default": false,
324
+ "description": `Whether the ${useTables ? 'column' : 'attribute'} is required`
325
+ },
326
+ "array": {
327
+ "type": "boolean",
328
+ "default": false,
329
+ "description": `Whether the ${useTables ? 'column' : 'attribute'} is an array`
330
+ },
331
+ ...(useTables ? {
332
+ "unique": {
333
+ "type": "boolean",
334
+ "default": false,
335
+ "description": "Whether the column values must be unique (TablesDB feature)"
336
+ }
337
+ } : {}),
338
+ "default": {
339
+ "description": `Default value for the ${useTables ? 'column' : 'attribute'}`
340
+ },
341
+ "description": {
342
+ "type": "string",
343
+ "description": `${useTables ? 'Column' : 'Attribute'} description`
344
+ },
345
+ "min": {
346
+ "type": "number",
347
+ "description": `Minimum value for numeric ${useTables ? 'columns' : 'attributes'}`
348
+ },
349
+ "max": {
350
+ "type": "number",
351
+ "description": `Maximum value for numeric ${useTables ? 'columns' : 'attributes'}`
352
+ },
353
+ "elements": {
354
+ "type": "array",
355
+ "items": {
356
+ "type": "string"
357
+ },
358
+ "description": `Allowed values for enum ${useTables ? 'columns' : 'attributes'}`
359
+ },
360
+ "relatedCollection": {
361
+ "type": "string",
362
+ "description": `Related ${useTables ? 'table' : 'collection'} name for relationship ${useTables ? 'columns' : 'attributes'}`
363
+ },
364
+ "relationType": {
365
+ "type": "string",
366
+ "enum": ["oneToOne", "oneToMany", "manyToOne", "manyToMany"],
367
+ "description": "Type of relationship"
368
+ },
369
+ "twoWay": {
370
+ "type": "boolean",
371
+ "description": "Whether the relationship is bidirectional"
372
+ },
373
+ "twoWayKey": {
374
+ "type": "string",
375
+ "description": "Key name for the reverse relationship"
376
+ },
377
+ "onDelete": {
378
+ "type": "string",
379
+ "enum": ["cascade", "restrict", "setNull"],
380
+ "description": `Action to take when related ${itemType} is deleted`
381
+ },
382
+ "side": {
383
+ "type": "string",
384
+ "enum": ["parent", "child"],
385
+ "description": "Side of the relationship"
386
+ }
387
+ },
388
+ "required": ["key", "type"],
389
+ "additionalProperties": false,
390
+ "allOf": [
391
+ {
392
+ "if": {
393
+ "properties": { "type": { "const": "enum" } }
394
+ },
395
+ "then": {
396
+ "required": ["elements"]
397
+ }
398
+ },
399
+ {
400
+ "if": {
401
+ "properties": { "type": { "const": "relationship" } }
402
+ },
403
+ "then": {
404
+ "required": ["relatedCollection", "relationType"]
405
+ }
406
+ }
407
+ ]
408
+ }
409
+ },
410
+ "indexes": {
411
+ "type": "array",
412
+ "description": `Database indexes for the ${useTables ? 'table' : 'collection'}`,
413
+ "items": {
414
+ "type": "object",
415
+ "properties": {
416
+ "key": {
417
+ "type": "string",
418
+ "description": "Index name"
419
+ },
420
+ "type": {
421
+ "type": "string",
422
+ "enum": ["key", "fulltext", "unique"],
423
+ "description": "Index type"
424
+ },
425
+ "attributes": {
426
+ "type": "array",
427
+ "items": {
428
+ "type": "string"
429
+ },
430
+ "description": `${useTables ? 'Columns' : 'Attributes'} to index`,
431
+ "minItems": 1
432
+ },
433
+ "orders": {
434
+ "type": "array",
435
+ "items": {
436
+ "type": "string",
437
+ "enum": ["ASC", "DESC"]
438
+ },
439
+ "description": `Sort order for each ${useTables ? 'column' : 'attribute'}`
440
+ }
441
+ },
442
+ "required": ["key", "type", "attributes"],
443
+ "additionalProperties": false
444
+ }
445
+ },
446
+ "importDefs": {
447
+ "type": "array",
448
+ "description": "Import definitions for data migration",
449
+ "default": []
450
+ }
451
+ },
452
+ "required": ["name"],
453
+ "additionalProperties": false
454
+ };
455
+
456
+ const schemaPath = path.join(yamlSchemaFolder, schemaFileName);
457
+ writeFileSync(schemaPath, JSON.stringify(schema, null, 2));
458
+
459
+ return schemaPath;
460
+ }
461
+
462
+ /**
463
+ * Initialize a new Appwrite project with correct directory structure and terminology
464
+ */
465
+ export async function initProject(
466
+ basePath?: string,
467
+ forceApiMode?: 'legacy' | 'tablesdb'
468
+ ): Promise<void> {
469
+ const projectPath = basePath || process.cwd();
470
+
471
+ // Detect API mode
472
+ const detection = forceApiMode
473
+ ? {
474
+ apiMode: forceApiMode,
475
+ useTables: forceApiMode === 'tablesdb',
476
+ detectionSource: 'forced' as const,
477
+ }
478
+ : await detectApiMode(projectPath);
479
+
480
+ const { useTables, detectionSource } = detection;
481
+ const terminology = getTerminologyConfig(useTables);
482
+
483
+ // Create directory structure
484
+ const dirs = createProjectDirectories(projectPath, useTables);
485
+
486
+ // Generate YAML config
487
+ const configPath = path.join(dirs.appwriteFolder, "config.yaml");
488
+ generateYamlConfigTemplate(configPath);
489
+
490
+ // Create example schema file
491
+ createExampleSchema(dirs.containerFolder, terminology);
492
+
493
+ // Create JSON validation schema
494
+ createYamlValidationSchema(dirs.yamlSchemaFolder, useTables);
495
+
496
+ // Success messages
497
+ const containerType = useTables ? "TablesDB" : "Collections";
498
+ MessageFormatter.success(
499
+ `Created YAML config and setup files/directories in .appwrite/ folder.`,
500
+ { prefix: "Setup" }
501
+ );
502
+ MessageFormatter.info(
503
+ `Project configured for ${containerType} API (${detectionSource} detection)`,
504
+ { prefix: "Setup" }
505
+ );
506
+ MessageFormatter.info("You can now configure your project in .appwrite/config.yaml", { prefix: "Setup" });
507
+ MessageFormatter.info(
508
+ `${terminology.containerName}s can be defined in .appwrite/${terminology.container}s/ as .yaml files`,
509
+ { prefix: "Setup" }
510
+ );
511
+ MessageFormatter.info("Schemas will be generated in .appwrite/schemas/", { prefix: "Setup" });
512
+ MessageFormatter.info("Import data can be placed in .appwrite/importData/", { prefix: "Setup" });
513
+
514
+ if (useTables) {
515
+ MessageFormatter.info(
516
+ "TablesDB features: unique constraints, enhanced performance, row-level security",
517
+ { prefix: "Setup" }
518
+ );
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Create a new collection or table schema file
524
+ */
525
+ export async function createSchema(
526
+ name: string,
527
+ basePath?: string,
528
+ forceApiMode?: 'legacy' | 'tablesdb'
529
+ ): Promise<void> {
530
+ const projectPath = basePath || process.cwd();
531
+
532
+ // Detect API mode
533
+ const detection = forceApiMode
534
+ ? {
535
+ apiMode: forceApiMode,
536
+ useTables: forceApiMode === 'tablesdb',
537
+ detectionSource: 'forced' as const,
538
+ }
539
+ : await detectApiMode(projectPath);
540
+
541
+ const { useTables } = detection;
542
+ const terminology = getTerminologyConfig(useTables);
543
+
544
+ // Find or create container directory
545
+ const appwriteFolder = path.join(projectPath, ".appwrite");
546
+ const containerFolder = path.join(appwriteFolder, useTables ? "tables" : "collections");
547
+
548
+ if (!existsSync(containerFolder)) {
549
+ mkdirSync(containerFolder, { recursive: true });
550
+ }
551
+
552
+ // Create YAML schema file
553
+ const yamlSchema = `# yaml-language-server: $schema=../.yaml_schemas/${terminology.schemaRef}
554
+ # ${terminology.containerName} Definition: ${name}
555
+ name: ${name}
556
+ id: ${ulid()}
557
+ ${terminology.security}: false
558
+ enabled: true
559
+ permissions:
560
+ - permission: read
561
+ target: any
562
+ - permission: create
563
+ target: users
564
+ - permission: update
565
+ target: users
566
+ - permission: delete
567
+ target: users
568
+ ${terminology.fields}:
569
+ # Add your ${terminology.fields} here
570
+ # Example:
571
+ # - key: title
572
+ # type: string
573
+ # size: 255
574
+ # required: true
575
+ # description: "The title of the item"
576
+ indexes:
577
+ # Add your indexes here
578
+ # Example:
579
+ # - key: title_search
580
+ # type: fulltext
581
+ # attributes:
582
+ # - title
583
+ importDefs: []
584
+ `;
585
+
586
+ const schemaPath = path.join(containerFolder, `${name}.yaml`);
587
+ writeFileSync(schemaPath, yamlSchema);
588
+
589
+ MessageFormatter.success(
590
+ `Created ${terminology.container} schema: ${schemaPath}`,
591
+ { prefix: "Setup" }
592
+ );
593
+ MessageFormatter.info(
594
+ `Add your ${terminology.fields} to define the ${terminology.container} structure`,
595
+ { prefix: "Setup" }
596
+ );
597
+ }