melaka 0.0.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.
Files changed (49) hide show
  1. package/CONTRIBUTING.md +347 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/docs/AI_PROVIDERS.md +343 -0
  5. package/docs/ARCHITECTURE.md +512 -0
  6. package/docs/CLI.md +438 -0
  7. package/docs/CONFIGURATION.md +453 -0
  8. package/docs/INTEGRATION.md +477 -0
  9. package/docs/ROADMAP.md +248 -0
  10. package/package.json +46 -0
  11. package/packages/ai/README.md +43 -0
  12. package/packages/ai/package.json +42 -0
  13. package/packages/ai/src/facade.ts +120 -0
  14. package/packages/ai/src/index.ts +34 -0
  15. package/packages/ai/src/prompt.ts +117 -0
  16. package/packages/ai/src/providers/gemini.ts +185 -0
  17. package/packages/ai/src/providers/index.ts +9 -0
  18. package/packages/ai/src/types.ts +134 -0
  19. package/packages/ai/tsconfig.json +19 -0
  20. package/packages/cli/README.md +70 -0
  21. package/packages/cli/package.json +44 -0
  22. package/packages/cli/src/cli.ts +30 -0
  23. package/packages/cli/src/commands/deploy.ts +115 -0
  24. package/packages/cli/src/commands/index.ts +9 -0
  25. package/packages/cli/src/commands/init.ts +107 -0
  26. package/packages/cli/src/commands/status.ts +73 -0
  27. package/packages/cli/src/commands/translate.ts +92 -0
  28. package/packages/cli/src/commands/validate.ts +69 -0
  29. package/packages/cli/tsconfig.json +19 -0
  30. package/packages/core/README.md +46 -0
  31. package/packages/core/package.json +50 -0
  32. package/packages/core/src/config.ts +241 -0
  33. package/packages/core/src/index.ts +111 -0
  34. package/packages/core/src/schema-generator.ts +263 -0
  35. package/packages/core/src/schemas.ts +126 -0
  36. package/packages/core/src/types.ts +481 -0
  37. package/packages/core/src/utils.ts +343 -0
  38. package/packages/core/tsconfig.json +19 -0
  39. package/packages/firestore/README.md +60 -0
  40. package/packages/firestore/package.json +48 -0
  41. package/packages/firestore/src/generator.ts +270 -0
  42. package/packages/firestore/src/i18n.ts +262 -0
  43. package/packages/firestore/src/index.ts +54 -0
  44. package/packages/firestore/src/processor.ts +245 -0
  45. package/packages/firestore/src/queue.ts +202 -0
  46. package/packages/firestore/src/task-handler.ts +164 -0
  47. package/packages/firestore/tsconfig.json +19 -0
  48. package/pnpm-workspace.yaml +2 -0
  49. package/turbo.json +31 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Melaka Firestore - Translation Processor
3
+ *
4
+ * Core translation logic that processes documents and writes translations.
5
+ */
6
+
7
+ import type { DocumentReference, DocumentData, Timestamp } from 'firebase-admin/firestore';
8
+ import {
9
+ separateContent,
10
+ hashContent,
11
+ mergeGlossaries,
12
+ createTranslationSchema,
13
+ type MelakaConfig,
14
+ type CollectionConfig,
15
+ type MelakaMetadata,
16
+ getEffectiveAIConfig,
17
+ } from '@melaka/core';
18
+ import { createTranslationFacade, type TranslationResponse } from '@melaka/ai';
19
+ import {
20
+ readMelakaMetadata,
21
+ writeTranslation,
22
+ markTranslationFailed,
23
+ } from './i18n';
24
+
25
+ /**
26
+ * Options for processing a translation.
27
+ */
28
+ export interface ProcessOptions {
29
+ /**
30
+ * Force re-translation even if content unchanged.
31
+ */
32
+ forceUpdate?: boolean;
33
+
34
+ /**
35
+ * Timestamp to use for translated_at field.
36
+ */
37
+ timestamp?: Timestamp;
38
+ }
39
+
40
+ /**
41
+ * Result of processing a translation.
42
+ */
43
+ export interface ProcessResult {
44
+ /**
45
+ * Whether processing succeeded.
46
+ */
47
+ success: boolean;
48
+
49
+ /**
50
+ * Whether translation was skipped (content unchanged).
51
+ */
52
+ skipped: boolean;
53
+
54
+ /**
55
+ * Error message if failed.
56
+ */
57
+ error?: string;
58
+
59
+ /**
60
+ * Duration in milliseconds.
61
+ */
62
+ durationMs?: number;
63
+ }
64
+
65
+ /**
66
+ * Process a single document translation.
67
+ *
68
+ * @param docRef - Document reference to translate
69
+ * @param docData - Document data
70
+ * @param targetLanguage - Target locale code
71
+ * @param config - Root Melaka configuration
72
+ * @param collectionConfig - Collection-specific configuration
73
+ * @param options - Processing options
74
+ * @returns Processing result
75
+ */
76
+ export async function processTranslation(
77
+ docRef: DocumentReference,
78
+ docData: DocumentData,
79
+ targetLanguage: string,
80
+ config: MelakaConfig,
81
+ collectionConfig: CollectionConfig,
82
+ options: ProcessOptions = {}
83
+ ): Promise<ProcessResult> {
84
+ const startTime = Date.now();
85
+
86
+ try {
87
+ // 1. Separate content into translatable and non-translatable
88
+ const { translatable, nonTranslatable, detectedTypes } = separateContent(
89
+ docData as Record<string, unknown>,
90
+ collectionConfig.fields
91
+ );
92
+
93
+ // Check if there's anything to translate
94
+ if (Object.keys(translatable).length === 0) {
95
+ return {
96
+ success: true,
97
+ skipped: true,
98
+ durationMs: Date.now() - startTime,
99
+ };
100
+ }
101
+
102
+ // 2. Calculate content hash for change detection
103
+ const sourceHash = hashContent(translatable);
104
+
105
+ // 3. Check if translation is already current (unless forceUpdate)
106
+ if (!options.forceUpdate) {
107
+ const existingMetadata = await readMelakaMetadata(docRef, targetLanguage);
108
+
109
+ if (
110
+ existingMetadata &&
111
+ existingMetadata.status === 'completed' &&
112
+ existingMetadata.source_hash === sourceHash
113
+ ) {
114
+ return {
115
+ success: true,
116
+ skipped: true,
117
+ durationMs: Date.now() - startTime,
118
+ };
119
+ }
120
+ }
121
+
122
+ // 4. Create translation schema
123
+ const schema = createTranslationSchema({ translatable, detectedTypes });
124
+
125
+ // 5. Get effective AI config and create facade
126
+ const aiConfig = getEffectiveAIConfig(config, collectionConfig);
127
+ const facade = createTranslationFacade(aiConfig);
128
+
129
+ // 6. Merge glossaries
130
+ const glossary = mergeGlossaries(config.glossary, collectionConfig.glossary);
131
+
132
+ // 7. Perform translation
133
+ const response: TranslationResponse<Record<string, unknown>> = await facade.translate(
134
+ translatable,
135
+ schema,
136
+ {
137
+ targetLanguage,
138
+ prompt: collectionConfig.prompt,
139
+ glossary,
140
+ temperature: aiConfig.temperature,
141
+ }
142
+ );
143
+
144
+ // 8. Handle translation result
145
+ const timestamp = options.timestamp || (await getTimestamp(docRef));
146
+
147
+ if (!response.success || !response.output) {
148
+ await markTranslationFailed(
149
+ docRef,
150
+ targetLanguage,
151
+ response.error || 'Unknown translation error',
152
+ timestamp
153
+ );
154
+
155
+ return {
156
+ success: false,
157
+ skipped: false,
158
+ error: response.error,
159
+ durationMs: Date.now() - startTime,
160
+ };
161
+ }
162
+
163
+ // 9. Create metadata
164
+ const metadata: MelakaMetadata = {
165
+ source_hash: sourceHash,
166
+ translated_at: timestamp,
167
+ model: facade.getModel(),
168
+ status: 'completed',
169
+ reviewed: false,
170
+ };
171
+
172
+ // 10. Write translation
173
+ await writeTranslation(
174
+ docRef,
175
+ targetLanguage,
176
+ response.output,
177
+ nonTranslatable,
178
+ metadata
179
+ );
180
+
181
+ return {
182
+ success: true,
183
+ skipped: false,
184
+ durationMs: Date.now() - startTime,
185
+ };
186
+ } catch (error) {
187
+ const timestamp = options.timestamp || (await getTimestamp(docRef));
188
+
189
+ const errorMessage = error instanceof Error ? error.message : String(error);
190
+
191
+ try {
192
+ await markTranslationFailed(docRef, targetLanguage, errorMessage, timestamp);
193
+ } catch {
194
+ // Ignore errors while marking failed
195
+ }
196
+
197
+ return {
198
+ success: false,
199
+ skipped: false,
200
+ error: errorMessage,
201
+ durationMs: Date.now() - startTime,
202
+ };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Process translations for all configured languages.
208
+ *
209
+ * @param docRef - Document reference to translate
210
+ * @param docData - Document data
211
+ * @param config - Root Melaka configuration
212
+ * @param collectionConfig - Collection-specific configuration
213
+ * @param options - Processing options
214
+ * @returns Results for each language
215
+ */
216
+ export async function processAllLanguages(
217
+ docRef: DocumentReference,
218
+ docData: DocumentData,
219
+ config: MelakaConfig,
220
+ collectionConfig: CollectionConfig,
221
+ options: ProcessOptions = {}
222
+ ): Promise<Record<string, ProcessResult>> {
223
+ const results: Record<string, ProcessResult> = {};
224
+
225
+ for (const language of config.languages) {
226
+ results[language] = await processTranslation(
227
+ docRef,
228
+ docData,
229
+ language,
230
+ config,
231
+ collectionConfig,
232
+ options
233
+ );
234
+ }
235
+
236
+ return results;
237
+ }
238
+
239
+ /**
240
+ * Get a Firestore Timestamp.
241
+ */
242
+ async function getTimestamp(docRef: DocumentReference): Promise<Timestamp> {
243
+ const { Timestamp } = await import('firebase-admin/firestore');
244
+ return Timestamp.now();
245
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Melaka Firestore - Task Queue
3
+ *
4
+ * Functions for enqueueing translation tasks to Cloud Tasks.
5
+ */
6
+
7
+ import type { Firestore } from 'firebase-admin/firestore';
8
+ import {
9
+ generateBatchId,
10
+ type MelakaConfig,
11
+ type CollectionConfig,
12
+ getEffectiveMaxConcurrency,
13
+ } from '@melaka/core';
14
+ import type { TranslationTaskPayload } from '@melaka/core';
15
+ import { createTaskPayload } from './task-handler';
16
+
17
+ /**
18
+ * Task queue interface for Cloud Functions.
19
+ */
20
+ export interface TaskQueue {
21
+ enqueue(
22
+ data: TranslationTaskPayload,
23
+ options?: { scheduleDelaySeconds?: number }
24
+ ): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Options for enqueueing translations.
29
+ */
30
+ export interface EnqueueOptions {
31
+ /**
32
+ * Task queue instance.
33
+ */
34
+ queue: TaskQueue;
35
+
36
+ /**
37
+ * Base delay between task scheduling (staggering).
38
+ */
39
+ staggerDelayMs?: number;
40
+ }
41
+
42
+ /**
43
+ * Result of enqueueing translations.
44
+ */
45
+ export interface EnqueueResult {
46
+ /**
47
+ * Batch ID for tracking.
48
+ */
49
+ batchId: string;
50
+
51
+ /**
52
+ * Number of tasks enqueued.
53
+ */
54
+ tasksEnqueued: number;
55
+
56
+ /**
57
+ * Number of tasks that failed to enqueue.
58
+ */
59
+ failed: number;
60
+
61
+ /**
62
+ * Error messages for failed tasks.
63
+ */
64
+ errors: string[];
65
+ }
66
+
67
+ /**
68
+ * Enqueue translation tasks for a single document.
69
+ *
70
+ * Creates one task per target language.
71
+ *
72
+ * @param collectionPath - Collection path
73
+ * @param documentId - Document ID
74
+ * @param config - Melaka configuration
75
+ * @param collectionConfig - Collection configuration
76
+ * @param options - Queue options
77
+ * @returns Enqueue result
78
+ */
79
+ export async function enqueueDocumentTranslation(
80
+ collectionPath: string,
81
+ documentId: string,
82
+ config: MelakaConfig,
83
+ collectionConfig: CollectionConfig,
84
+ options: EnqueueOptions
85
+ ): Promise<EnqueueResult> {
86
+ const batchId = generateBatchId();
87
+ const staggerDelay = options.staggerDelayMs || 100;
88
+ let tasksEnqueued = 0;
89
+ let failed = 0;
90
+ const errors: string[] = [];
91
+
92
+ for (let i = 0; i < config.languages.length; i++) {
93
+ const language = config.languages[i];
94
+
95
+ try {
96
+ const payload = createTaskPayload(
97
+ collectionPath,
98
+ documentId,
99
+ language,
100
+ collectionConfig,
101
+ batchId
102
+ );
103
+
104
+ await options.queue.enqueue(payload, {
105
+ scheduleDelaySeconds: Math.floor((i * staggerDelay) / 1000),
106
+ });
107
+
108
+ tasksEnqueued++;
109
+ } catch (error) {
110
+ failed++;
111
+ errors.push(
112
+ `Failed to enqueue ${documentId}/${language}: ${error instanceof Error ? error.message : String(error)}`
113
+ );
114
+ }
115
+ }
116
+
117
+ return {
118
+ batchId,
119
+ tasksEnqueued,
120
+ failed,
121
+ errors,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Enqueue translation tasks for all documents in a collection.
127
+ *
128
+ * @param firestore - Firestore instance
129
+ * @param config - Melaka configuration
130
+ * @param collectionConfig - Collection configuration
131
+ * @param options - Queue options
132
+ * @returns Enqueue result
133
+ */
134
+ export async function enqueueCollectionTranslation(
135
+ firestore: Firestore,
136
+ config: MelakaConfig,
137
+ collectionConfig: CollectionConfig,
138
+ options: EnqueueOptions
139
+ ): Promise<EnqueueResult> {
140
+ const batchId = generateBatchId();
141
+ const maxConcurrency = getEffectiveMaxConcurrency(config, collectionConfig);
142
+ const staggerDelay = options.staggerDelayMs || 100;
143
+
144
+ let tasksEnqueued = 0;
145
+ let failed = 0;
146
+ const errors: string[] = [];
147
+
148
+ // Query collection
149
+ let query: FirebaseFirestore.Query;
150
+ if (collectionConfig.isCollectionGroup) {
151
+ query = firestore.collectionGroup(collectionConfig.path);
152
+ } else {
153
+ query = firestore.collection(collectionConfig.path);
154
+ }
155
+
156
+ const snapshots = await query.select().get();
157
+ const totalDocs = snapshots.docs.length;
158
+ const totalTasks = totalDocs * config.languages.length;
159
+
160
+ let taskIndex = 0;
161
+
162
+ for (const doc of snapshots.docs) {
163
+ const collectionPath = collectionConfig.isCollectionGroup
164
+ ? doc.ref.parent.path
165
+ : collectionConfig.path;
166
+
167
+ for (const language of config.languages) {
168
+ try {
169
+ const payload = createTaskPayload(
170
+ collectionPath,
171
+ doc.id,
172
+ language,
173
+ collectionConfig,
174
+ batchId
175
+ );
176
+
177
+ // Calculate delay for staggering
178
+ const delaySeconds = Math.floor((taskIndex * staggerDelay) / 1000);
179
+
180
+ await options.queue.enqueue(payload, {
181
+ scheduleDelaySeconds: delaySeconds,
182
+ });
183
+
184
+ tasksEnqueued++;
185
+ } catch (error) {
186
+ failed++;
187
+ errors.push(
188
+ `Failed to enqueue ${doc.id}/${language}: ${error instanceof Error ? error.message : String(error)}`
189
+ );
190
+ }
191
+
192
+ taskIndex++;
193
+ }
194
+ }
195
+
196
+ return {
197
+ batchId,
198
+ tasksEnqueued,
199
+ failed,
200
+ errors,
201
+ };
202
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Melaka Firestore - Cloud Task Handler
3
+ *
4
+ * Task queue handler for async translation processing.
5
+ */
6
+
7
+ import type { DocumentReference } from 'firebase-admin/firestore';
8
+ import {
9
+ TranslationTaskPayloadSchema,
10
+ type TranslationTaskPayload,
11
+ type MelakaConfig,
12
+ findCollectionConfig,
13
+ } from '@melaka/core';
14
+ import { processTranslation, type ProcessResult } from './processor';
15
+
16
+ /**
17
+ * Task handler context with Firebase dependencies.
18
+ */
19
+ export interface TaskHandlerContext {
20
+ /**
21
+ * Firestore instance.
22
+ */
23
+ firestore: FirebaseFirestore.Firestore;
24
+
25
+ /**
26
+ * Melaka configuration.
27
+ */
28
+ config: MelakaConfig;
29
+
30
+ /**
31
+ * API key for AI provider (resolved from secret).
32
+ */
33
+ apiKey: string;
34
+ }
35
+
36
+ /**
37
+ * Handle a translation task from Cloud Tasks.
38
+ *
39
+ * This function is called by the `onTaskDispatched` Cloud Function.
40
+ *
41
+ * @param payload - Task payload
42
+ * @param context - Handler context with Firestore and config
43
+ * @returns Processing result
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // In generated Cloud Function:
48
+ * export const melakaTranslateTask = onTaskDispatched(
49
+ * { secrets: [geminiApiKey] },
50
+ * async (request) => {
51
+ * const result = await handleTranslationTask(request.data, {
52
+ * firestore: getFirestore(),
53
+ * config: await loadConfig(),
54
+ * apiKey: geminiApiKey.value(),
55
+ * });
56
+ *
57
+ * if (!result.success) {
58
+ * throw new Error(result.error);
59
+ * }
60
+ * }
61
+ * );
62
+ * ```
63
+ */
64
+ export async function handleTranslationTask(
65
+ payload: unknown,
66
+ context: TaskHandlerContext
67
+ ): Promise<ProcessResult> {
68
+ // 1. Validate payload
69
+ const parseResult = TranslationTaskPayloadSchema.safeParse(payload);
70
+ if (!parseResult.success) {
71
+ return {
72
+ success: false,
73
+ skipped: false,
74
+ error: `Invalid task payload: ${parseResult.error.message}`,
75
+ };
76
+ }
77
+
78
+ const taskPayload = parseResult.data as TranslationTaskPayload;
79
+
80
+ // 2. Get document reference
81
+ const docRef = context.firestore
82
+ .collection(taskPayload.collectionPath)
83
+ .doc(taskPayload.documentId);
84
+
85
+ // 3. Read document
86
+ const docSnapshot = await docRef.get();
87
+ if (!docSnapshot.exists) {
88
+ return {
89
+ success: true,
90
+ skipped: true,
91
+ error: 'Document no longer exists',
92
+ };
93
+ }
94
+
95
+ const docData = docSnapshot.data();
96
+ if (!docData) {
97
+ return {
98
+ success: true,
99
+ skipped: true,
100
+ error: 'Document has no data',
101
+ };
102
+ }
103
+
104
+ // 4. Find collection config
105
+ const collectionConfig = findCollectionConfig(
106
+ context.config,
107
+ taskPayload.collectionPath
108
+ );
109
+
110
+ if (!collectionConfig) {
111
+ return {
112
+ success: false,
113
+ skipped: false,
114
+ error: `Collection not configured: ${taskPayload.collectionPath}`,
115
+ };
116
+ }
117
+
118
+ // 5. Inject API key into config
119
+ const configWithKey: MelakaConfig = {
120
+ ...context.config,
121
+ ai: {
122
+ ...context.config.ai,
123
+ apiKey: context.apiKey,
124
+ },
125
+ };
126
+
127
+ // 6. Process translation
128
+ return processTranslation(
129
+ docRef,
130
+ docData,
131
+ taskPayload.targetLanguage,
132
+ configWithKey,
133
+ collectionConfig,
134
+ {
135
+ forceUpdate: collectionConfig.forceUpdate,
136
+ }
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Create task payload for enqueueing.
142
+ *
143
+ * @param collectionPath - Collection path
144
+ * @param documentId - Document ID
145
+ * @param targetLanguage - Target locale
146
+ * @param config - Collection configuration
147
+ * @param batchId - Batch identifier
148
+ * @returns Task payload
149
+ */
150
+ export function createTaskPayload(
151
+ collectionPath: string,
152
+ documentId: string,
153
+ targetLanguage: string,
154
+ config: import('@melaka/core').CollectionConfig,
155
+ batchId: string
156
+ ): TranslationTaskPayload {
157
+ return {
158
+ collectionPath,
159
+ documentId,
160
+ targetLanguage,
161
+ config,
162
+ batchId,
163
+ };
164
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "outDir": "./dist",
15
+ "rootDir": "./src"
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }
@@ -0,0 +1,2 @@
1
+ packages:
2
+ - "packages/*"
package/turbo.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://turbo.build/schema.json",
3
+ "globalDependencies": ["**/.env.*local"],
4
+ "tasks": {
5
+ "build": {
6
+ "dependsOn": ["^build"],
7
+ "outputs": ["dist/**"]
8
+ },
9
+ "dev": {
10
+ "cache": false,
11
+ "persistent": true
12
+ },
13
+ "test": {
14
+ "dependsOn": ["build"],
15
+ "outputs": ["coverage/**"]
16
+ },
17
+ "test:watch": {
18
+ "cache": false,
19
+ "persistent": true
20
+ },
21
+ "lint": {
22
+ "dependsOn": ["^build"]
23
+ },
24
+ "typecheck": {
25
+ "dependsOn": ["^build"]
26
+ },
27
+ "clean": {
28
+ "cache": false
29
+ }
30
+ }
31
+ }