@contractspec/lib.runtime-sandbox 0.11.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 (63) hide show
  1. package/dist/_virtual/rolldown_runtime.js +18 -0
  2. package/dist/adapters/pglite/adapter.js +97 -0
  3. package/dist/adapters/pglite/adapter.js.map +1 -0
  4. package/dist/adapters/pglite/index.js +3 -0
  5. package/dist/index.d.ts +23 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +24 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/ports/database.port.d.ts +70 -0
  10. package/dist/ports/database.port.d.ts.map +1 -0
  11. package/dist/types/database.types.d.ts +47 -0
  12. package/dist/types/database.types.d.ts.map +1 -0
  13. package/dist/web/database/migrations.d.ts +12 -0
  14. package/dist/web/database/migrations.d.ts.map +1 -0
  15. package/dist/web/database/migrations.js +746 -0
  16. package/dist/web/database/migrations.js.map +1 -0
  17. package/dist/web/database/schema.d.ts +7349 -0
  18. package/dist/web/database/schema.d.ts.map +1 -0
  19. package/dist/web/database/schema.js +528 -0
  20. package/dist/web/database/schema.js.map +1 -0
  21. package/dist/web/events/local-pubsub.d.ts +10 -0
  22. package/dist/web/events/local-pubsub.d.ts.map +1 -0
  23. package/dist/web/events/local-pubsub.js +24 -0
  24. package/dist/web/events/local-pubsub.js.map +1 -0
  25. package/dist/web/graphql/local-client.d.ts +20 -0
  26. package/dist/web/graphql/local-client.d.ts.map +1 -0
  27. package/dist/web/graphql/local-client.js +536 -0
  28. package/dist/web/graphql/local-client.js.map +1 -0
  29. package/dist/web/index.d.ts +15 -0
  30. package/dist/web/index.d.ts.map +1 -0
  31. package/dist/web/index.js +68 -0
  32. package/dist/web/index.js.map +1 -0
  33. package/dist/web/runtime/seeders/index.js +358 -0
  34. package/dist/web/runtime/seeders/index.js.map +1 -0
  35. package/dist/web/runtime/services.d.ts +60 -0
  36. package/dist/web/runtime/services.d.ts.map +1 -0
  37. package/dist/web/runtime/services.js +80 -0
  38. package/dist/web/runtime/services.js.map +1 -0
  39. package/dist/web/storage/indexeddb.d.ts +22 -0
  40. package/dist/web/storage/indexeddb.d.ts.map +1 -0
  41. package/dist/web/storage/indexeddb.js +85 -0
  42. package/dist/web/storage/indexeddb.js.map +1 -0
  43. package/dist/web/utils/id.d.ts +5 -0
  44. package/dist/web/utils/id.d.ts.map +1 -0
  45. package/dist/web/utils/id.js +9 -0
  46. package/dist/web/utils/id.js.map +1 -0
  47. package/package.json +70 -0
  48. package/src/adapters/pglite/adapter.ts +152 -0
  49. package/src/adapters/pglite/index.ts +1 -0
  50. package/src/index.ts +41 -0
  51. package/src/ports/database.port.ts +82 -0
  52. package/src/ports/index.ts +4 -0
  53. package/src/types/database.types.ts +55 -0
  54. package/src/types/index.ts +1 -0
  55. package/src/web/database/migrations.ts +760 -0
  56. package/src/web/database/schema.ts +596 -0
  57. package/src/web/events/local-pubsub.ts +28 -0
  58. package/src/web/graphql/local-client.ts +747 -0
  59. package/src/web/index.ts +21 -0
  60. package/src/web/runtime/seeders/index.ts +449 -0
  61. package/src/web/runtime/services.ts +132 -0
  62. package/src/web/storage/indexeddb.ts +116 -0
  63. package/src/web/utils/id.ts +7 -0
@@ -0,0 +1,747 @@
1
+ import { ApolloClient, InMemoryCache } from '@apollo/client';
2
+ import { SchemaLink } from '@apollo/client/link/schema';
3
+ import { makeExecutableSchema } from '@graphql-tools/schema';
4
+ import { GraphQLScalarType, Kind } from 'graphql';
5
+
6
+ import type { DatabasePort, DbRow } from '@contractspec/lib.runtime-sandbox';
7
+ import { LocalEventBus } from '../events/local-pubsub';
8
+ import { LocalStorageService } from '../storage/indexeddb';
9
+ import { generateId } from '../utils/id';
10
+
11
+ const typeDefs = /* GraphQL */ `
12
+ scalar DateTime
13
+
14
+ enum TaskPriority {
15
+ LOW
16
+ MEDIUM
17
+ HIGH
18
+ URGENT
19
+ }
20
+
21
+ enum MessageStatus {
22
+ SENT
23
+ DELIVERED
24
+ READ
25
+ }
26
+
27
+ enum RecipeLocale {
28
+ EN
29
+ FR
30
+ }
31
+
32
+ type TaskCategory {
33
+ id: ID!
34
+ projectId: ID!
35
+ name: String!
36
+ color: String
37
+ createdAt: DateTime!
38
+ updatedAt: DateTime!
39
+ }
40
+
41
+ type Task {
42
+ id: ID!
43
+ projectId: ID!
44
+ categoryId: ID
45
+ title: String!
46
+ description: String
47
+ completed: Boolean!
48
+ priority: TaskPriority!
49
+ dueDate: DateTime
50
+ tags: [String!]!
51
+ createdAt: DateTime!
52
+ updatedAt: DateTime!
53
+ category: TaskCategory
54
+ }
55
+
56
+ input CreateTaskInput {
57
+ projectId: ID!
58
+ categoryId: ID
59
+ title: String!
60
+ description: String
61
+ priority: TaskPriority = MEDIUM
62
+ dueDate: DateTime
63
+ tags: [String!]
64
+ }
65
+
66
+ input UpdateTaskInput {
67
+ categoryId: ID
68
+ title: String
69
+ description: String
70
+ priority: TaskPriority
71
+ dueDate: DateTime
72
+ tags: [String!]
73
+ }
74
+
75
+ type ConversationParticipant {
76
+ id: ID!
77
+ conversationId: ID!
78
+ projectId: ID!
79
+ userId: String!
80
+ displayName: String
81
+ role: String
82
+ joinedAt: DateTime!
83
+ lastReadAt: DateTime
84
+ }
85
+
86
+ input ConversationParticipantInput {
87
+ userId: String!
88
+ displayName: String
89
+ role: String
90
+ }
91
+
92
+ type Message {
93
+ id: ID!
94
+ conversationId: ID!
95
+ projectId: ID!
96
+ senderId: String!
97
+ senderName: String
98
+ content: String!
99
+ attachments: [String!]!
100
+ status: MessageStatus!
101
+ createdAt: DateTime!
102
+ updatedAt: DateTime!
103
+ }
104
+
105
+ input SendMessageInput {
106
+ conversationId: ID!
107
+ projectId: ID!
108
+ senderId: String!
109
+ senderName: String
110
+ content: String!
111
+ }
112
+
113
+ input CreateConversationInput {
114
+ projectId: ID!
115
+ name: String
116
+ isGroup: Boolean = false
117
+ avatarUrl: String
118
+ participants: [ConversationParticipantInput!]!
119
+ }
120
+
121
+ type Conversation {
122
+ id: ID!
123
+ projectId: ID!
124
+ name: String
125
+ isGroup: Boolean!
126
+ avatarUrl: String
127
+ lastMessageId: ID
128
+ updatedAt: DateTime!
129
+ participants: [ConversationParticipant!]!
130
+ messages(limit: Int = 50): [Message!]!
131
+ }
132
+
133
+ type RecipeCategory {
134
+ id: ID!
135
+ nameEn: String!
136
+ nameFr: String!
137
+ icon: String
138
+ }
139
+
140
+ type RecipeIngredient {
141
+ id: ID!
142
+ name: String!
143
+ quantity: String!
144
+ ordering: Int!
145
+ }
146
+
147
+ type RecipeInstruction {
148
+ id: ID!
149
+ content: String!
150
+ ordering: Int!
151
+ }
152
+
153
+ type Recipe {
154
+ id: ID!
155
+ projectId: ID!
156
+ slugEn: String!
157
+ slugFr: String!
158
+ name: String!
159
+ description: String
160
+ heroImageUrl: String
161
+ prepTimeMinutes: Int
162
+ cookTimeMinutes: Int
163
+ servings: Int
164
+ isFavorite: Boolean!
165
+ locale: RecipeLocale!
166
+ category: RecipeCategory
167
+ ingredients: [RecipeIngredient!]!
168
+ instructions: [RecipeInstruction!]!
169
+ }
170
+
171
+ type Query {
172
+ taskCategories(projectId: ID!): [TaskCategory!]!
173
+ tasks(projectId: ID!): [Task!]!
174
+ conversations(projectId: ID!): [Conversation!]!
175
+ messages(conversationId: ID!, limit: Int = 50): [Message!]!
176
+ recipes(projectId: ID!, locale: RecipeLocale = EN): [Recipe!]!
177
+ recipe(id: ID!, locale: RecipeLocale = EN): Recipe
178
+ }
179
+
180
+ type Mutation {
181
+ createTask(input: CreateTaskInput!): Task!
182
+ updateTask(id: ID!, input: UpdateTaskInput!): Task!
183
+ toggleTask(id: ID!, completed: Boolean!): Task!
184
+ deleteTask(id: ID!): Boolean!
185
+ createConversation(input: CreateConversationInput!): Conversation!
186
+ sendMessage(input: SendMessageInput!): Message!
187
+ setMessagesRead(conversationId: ID!, userId: String!): Boolean!
188
+ favoriteRecipe(id: ID!, isFavorite: Boolean!): Recipe!
189
+ }
190
+ `;
191
+
192
+ interface ResolverContext {
193
+ db: DatabasePort;
194
+ storage: LocalStorageService;
195
+ pubsub: LocalEventBus;
196
+ }
197
+
198
+ type ResolverParent = Record<string, unknown>;
199
+
200
+ /**
201
+ * Local row type for query results
202
+ */
203
+ type LocalRow = DbRow;
204
+
205
+ const DateTimeScalar = new GraphQLScalarType({
206
+ name: 'DateTime',
207
+ parseValue(value: unknown) {
208
+ return value ? new Date(value as string).toISOString() : null;
209
+ },
210
+ serialize(value: unknown) {
211
+ if (!value) return null;
212
+ if (typeof value === 'string') return value;
213
+ return new Date(value as string).toISOString();
214
+ },
215
+ parseLiteral(ast) {
216
+ if (ast.kind === Kind.STRING) {
217
+ return new Date(ast.value).toISOString();
218
+ }
219
+ return null;
220
+ },
221
+ });
222
+
223
+ export interface LocalGraphQLClientOptions {
224
+ db: DatabasePort;
225
+ storage: LocalStorageService;
226
+ pubsub?: LocalEventBus;
227
+ }
228
+
229
+ export class LocalGraphQLClient {
230
+ readonly apollo: InstanceType<typeof ApolloClient>;
231
+
232
+ constructor(private readonly options: LocalGraphQLClientOptions) {
233
+ const schema = makeExecutableSchema({
234
+ typeDefs,
235
+ resolvers: this.createResolvers(),
236
+ });
237
+
238
+ this.apollo = new ApolloClient({
239
+ cache: new InMemoryCache(),
240
+ link: new SchemaLink({
241
+ schema,
242
+ context: () => ({
243
+ db: this.options.db,
244
+ storage: this.options.storage,
245
+ pubsub: this.options.pubsub ?? new LocalEventBus(),
246
+ }),
247
+ }),
248
+ devtools: {
249
+ enabled: typeof window !== 'undefined',
250
+ },
251
+ });
252
+ }
253
+
254
+ private createResolvers() {
255
+ return {
256
+ DateTime: DateTimeScalar,
257
+ Query: {
258
+ taskCategories: async (
259
+ _: ResolverParent,
260
+ args: { projectId: string },
261
+ ctx: ResolverContext
262
+ ) => {
263
+ const result = await ctx.db.query(
264
+ `SELECT * FROM template_task_category WHERE "projectId" = $1 ORDER BY name ASC`,
265
+ [args.projectId]
266
+ );
267
+ return result.rows.map(mapTaskCategory);
268
+ },
269
+ tasks: async (
270
+ _: ResolverParent,
271
+ args: { projectId: string },
272
+ ctx: ResolverContext
273
+ ) => {
274
+ const result = await ctx.db.query(
275
+ `SELECT * FROM template_task WHERE "projectId" = $1 ORDER BY "createdAt" DESC`,
276
+ [args.projectId]
277
+ );
278
+ return result.rows.map(mapTask);
279
+ },
280
+ conversations: async (
281
+ _: ResolverParent,
282
+ args: { projectId: string },
283
+ ctx: ResolverContext
284
+ ) => {
285
+ const result = await ctx.db.query(
286
+ `SELECT * FROM template_conversation WHERE "projectId" = $1 ORDER BY "updatedAt" DESC`,
287
+ [args.projectId]
288
+ );
289
+ return result.rows.map(mapConversation);
290
+ },
291
+ messages: async (
292
+ _: ResolverParent,
293
+ args: { conversationId: string; limit: number },
294
+ ctx: ResolverContext
295
+ ) => {
296
+ const result = await ctx.db.query(
297
+ `SELECT * FROM template_message WHERE "conversationId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
298
+ [args.conversationId, args.limit]
299
+ );
300
+ return result.rows.map(mapMessage);
301
+ },
302
+ recipes: async (
303
+ _: ResolverParent,
304
+ args: { projectId: string; locale: 'EN' | 'FR' },
305
+ ctx: ResolverContext
306
+ ) => {
307
+ const result = await ctx.db.query(
308
+ `SELECT * FROM template_recipe WHERE "projectId" = $1 ORDER BY "nameEn" ASC`,
309
+ [args.projectId]
310
+ );
311
+ return result.rows.map((row: LocalRow) =>
312
+ mapRecipe(row, args.locale)
313
+ );
314
+ },
315
+ recipe: async (
316
+ _: ResolverParent,
317
+ args: { id: string; locale: 'EN' | 'FR' },
318
+ ctx: ResolverContext
319
+ ) => {
320
+ const result = await ctx.db.query(
321
+ `SELECT * FROM template_recipe WHERE id = $1 LIMIT 1`,
322
+ [args.id]
323
+ );
324
+ if (!result.rows.length || !result.rows[0]) return null;
325
+ return mapRecipe(result.rows[0], args.locale);
326
+ },
327
+ },
328
+ Mutation: {
329
+ createTask: async (
330
+ _: ResolverParent,
331
+ args: { input: Record<string, unknown> },
332
+ ctx: ResolverContext
333
+ ) => {
334
+ const id = generateId('task');
335
+ const now = new Date().toISOString();
336
+ const tags = JSON.stringify(args.input.tags ?? []);
337
+ await ctx.db.execute(
338
+ `INSERT INTO template_task (id, "projectId", "categoryId", title, description, completed, priority, "dueDate", tags, "createdAt", "updatedAt")
339
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
340
+ [
341
+ id,
342
+ args.input.projectId as string,
343
+ (args.input.categoryId as string | undefined) ?? null,
344
+ args.input.title as string,
345
+ (args.input.description as string | undefined) ?? null,
346
+ 0,
347
+ (args.input.priority as string | undefined) ?? 'MEDIUM',
348
+ (args.input.dueDate as string | undefined) ?? null,
349
+ tags,
350
+ now,
351
+ now,
352
+ ]
353
+ );
354
+ const result = await ctx.db.query(
355
+ `SELECT * FROM template_task WHERE id = $1 LIMIT 1`,
356
+ [id]
357
+ );
358
+ if (!result.rows.length || !result.rows[0])
359
+ throw new Error('Failed to create task');
360
+ return mapTask(result.rows[0]);
361
+ },
362
+ updateTask: async (
363
+ _: ResolverParent,
364
+ args: { id: string; input: Record<string, unknown> },
365
+ ctx: ResolverContext
366
+ ) => {
367
+ const now = new Date().toISOString();
368
+ await ctx.db.execute(
369
+ `UPDATE template_task
370
+ SET "categoryId" = COALESCE($1, "categoryId"),
371
+ title = COALESCE($2, title),
372
+ description = COALESCE($3, description),
373
+ priority = COALESCE($4, priority),
374
+ "dueDate" = COALESCE($5, "dueDate"),
375
+ tags = COALESCE($6, tags),
376
+ "updatedAt" = $7
377
+ WHERE id = $8`,
378
+ [
379
+ (args.input.categoryId as string | undefined) ?? null,
380
+ (args.input.title as string | undefined) ?? null,
381
+ (args.input.description as string | undefined) ?? null,
382
+ (args.input.priority as string | undefined) ?? null,
383
+ (args.input.dueDate as string | undefined) ?? null,
384
+ args.input.tags ? JSON.stringify(args.input.tags) : null,
385
+ now,
386
+ args.id,
387
+ ]
388
+ );
389
+ const result = await ctx.db.query(
390
+ `SELECT * FROM template_task WHERE id = $1 LIMIT 1`,
391
+ [args.id]
392
+ );
393
+ if (!result.rows.length || !result.rows[0])
394
+ throw new Error('Task not found');
395
+ return mapTask(result.rows[0]);
396
+ },
397
+ toggleTask: async (
398
+ _: ResolverParent,
399
+ args: { id: string; completed: boolean },
400
+ ctx: ResolverContext
401
+ ) => {
402
+ const now = new Date().toISOString();
403
+ await ctx.db.execute(
404
+ `UPDATE template_task SET completed = $1, "updatedAt" = $2 WHERE id = $3`,
405
+ [args.completed ? 1 : 0, now, args.id]
406
+ );
407
+ const result = await ctx.db.query(
408
+ `SELECT * FROM template_task WHERE id = $1 LIMIT 1`,
409
+ [args.id]
410
+ );
411
+ if (!result.rows.length || !result.rows[0])
412
+ throw new Error('Task not found');
413
+ return mapTask(result.rows[0]);
414
+ },
415
+ deleteTask: async (
416
+ _: ResolverParent,
417
+ args: { id: string },
418
+ ctx: ResolverContext
419
+ ) => {
420
+ await ctx.db.execute(`DELETE FROM template_task WHERE id = $1`, [
421
+ args.id,
422
+ ]);
423
+ return true;
424
+ },
425
+ createConversation: async (
426
+ _: ResolverParent,
427
+ args: { input: Record<string, unknown> },
428
+ ctx: ResolverContext
429
+ ) => {
430
+ const id = generateId('conversation');
431
+ const now = new Date().toISOString();
432
+ await ctx.db.execute(
433
+ `INSERT INTO template_conversation (id, "projectId", name, "isGroup", "avatarUrl", "updatedAt")
434
+ VALUES ($1, $2, $3, $4, $5, $6)`,
435
+ [
436
+ id,
437
+ args.input.projectId as string,
438
+ (args.input.name as string | undefined) ?? null,
439
+ (args.input.isGroup as boolean | undefined) ? 1 : 0,
440
+ (args.input.avatarUrl as string | undefined) ?? null,
441
+ now,
442
+ ]
443
+ );
444
+
445
+ const participants =
446
+ (args.input.participants as Record<string, string>[]) ?? [];
447
+ for (const participant of participants) {
448
+ await ctx.db.execute(
449
+ `INSERT INTO template_conversation_participant (id, "conversationId", "projectId", "userId", "displayName", role, "joinedAt")
450
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
451
+ [
452
+ generateId('participant'),
453
+ id,
454
+ args.input.projectId as string,
455
+ participant.userId,
456
+ participant.displayName ?? null,
457
+ participant.role ?? null,
458
+ now,
459
+ ]
460
+ );
461
+ }
462
+
463
+ const result = await ctx.db.query(
464
+ `SELECT * FROM template_conversation WHERE id = $1 LIMIT 1`,
465
+ [id]
466
+ );
467
+ if (!result.rows.length || !result.rows[0])
468
+ throw new Error('Failed to create conversation');
469
+ return mapConversation(result.rows[0]);
470
+ },
471
+ sendMessage: async (
472
+ _: ResolverParent,
473
+ args: { input: Record<string, unknown> },
474
+ ctx: ResolverContext
475
+ ) => {
476
+ const id = generateId('message');
477
+ const now = new Date().toISOString();
478
+ await ctx.db.execute(
479
+ `INSERT INTO template_message (id, "conversationId", "projectId", "senderId", "senderName", content, attachments, status, "createdAt", "updatedAt")
480
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
481
+ [
482
+ id,
483
+ args.input.conversationId as string,
484
+ args.input.projectId as string,
485
+ args.input.senderId as string,
486
+ (args.input.senderName as string | undefined) ?? null,
487
+ args.input.content as string,
488
+ JSON.stringify([]),
489
+ 'SENT',
490
+ now,
491
+ now,
492
+ ]
493
+ );
494
+ await ctx.db.execute(
495
+ `UPDATE template_conversation SET "lastMessageId" = $1, "updatedAt" = $2 WHERE id = $3`,
496
+ [id, now, args.input.conversationId as string]
497
+ );
498
+ const result = await ctx.db.query(
499
+ `SELECT * FROM template_message WHERE id = $1`,
500
+ [id]
501
+ );
502
+ if (!result.rows.length || !result.rows[0])
503
+ throw new Error('Failed to send message');
504
+ const message = mapMessage(result.rows[0]);
505
+ ctx.pubsub.emit('message:new', message);
506
+ return message;
507
+ },
508
+ setMessagesRead: async (
509
+ _: ResolverParent,
510
+ args: { conversationId: string; userId: string },
511
+ ctx: ResolverContext
512
+ ) => {
513
+ const now = new Date().toISOString();
514
+ await ctx.db.execute(
515
+ `UPDATE template_conversation_participant
516
+ SET "lastReadAt" = $1
517
+ WHERE "conversationId" = $2 AND "userId" = $3`,
518
+ [now, args.conversationId, args.userId]
519
+ );
520
+ return true;
521
+ },
522
+ favoriteRecipe: async (
523
+ _: ResolverParent,
524
+ args: { id: string; isFavorite: boolean },
525
+ ctx: ResolverContext
526
+ ) => {
527
+ const now = new Date().toISOString();
528
+ await ctx.db.execute(
529
+ `UPDATE template_recipe SET "isFavorite" = $1, "updatedAt" = $2 WHERE id = $3`,
530
+ [args.isFavorite ? 1 : 0, now, args.id]
531
+ );
532
+ const result = await ctx.db.query(
533
+ `SELECT * FROM template_recipe WHERE id = $1 LIMIT 1`,
534
+ [args.id]
535
+ );
536
+ if (!result.rows.length || !result.rows[0])
537
+ throw new Error('Recipe not found');
538
+ const locale: 'EN' | 'FR' = 'EN';
539
+ return mapRecipe(result.rows[0], locale);
540
+ },
541
+ },
542
+ Task: {
543
+ category: async (
544
+ parent: LocalRow,
545
+ _: unknown,
546
+ ctx: ResolverContext
547
+ ) => {
548
+ if (!parent.categoryId) return null;
549
+ const result = await ctx.db.query(
550
+ `SELECT * FROM template_task_category WHERE id = $1 LIMIT 1`,
551
+ [parent.categoryId]
552
+ );
553
+ if (!result.rows.length || !result.rows[0]) return null;
554
+ return mapTaskCategory(result.rows[0]);
555
+ },
556
+ },
557
+ Conversation: {
558
+ participants: async (
559
+ parent: LocalRow,
560
+ _: unknown,
561
+ ctx: ResolverContext
562
+ ) => {
563
+ const result = await ctx.db.query(
564
+ `SELECT * FROM template_conversation_participant WHERE "conversationId" = $1 ORDER BY "joinedAt" ASC`,
565
+ [parent.id]
566
+ );
567
+ return result.rows.map(mapParticipant);
568
+ },
569
+ messages: async (
570
+ parent: LocalRow,
571
+ args: { limit: number },
572
+ ctx: ResolverContext
573
+ ) => {
574
+ const result = await ctx.db.query(
575
+ `SELECT * FROM template_message WHERE "conversationId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
576
+ [parent.id, args.limit]
577
+ );
578
+ return result.rows.map(mapMessage);
579
+ },
580
+ },
581
+ Recipe: {
582
+ category: async (
583
+ parent: LocalRow & { categoryId?: string | null },
584
+ _: unknown,
585
+ ctx: ResolverContext
586
+ ) => {
587
+ if (!parent.categoryId) return null;
588
+ const result = await ctx.db.query(
589
+ `SELECT * FROM template_recipe_category WHERE id = $1 LIMIT 1`,
590
+ [parent.categoryId]
591
+ );
592
+ if (!result.rows.length || !result.rows[0]) return null;
593
+ return mapRecipeCategory(result.rows[0]);
594
+ },
595
+ ingredients: async (
596
+ parent: LocalRow & { locale: 'EN' | 'FR' },
597
+ _: unknown,
598
+ ctx: ResolverContext
599
+ ) => {
600
+ const result = await ctx.db.query(
601
+ `SELECT * FROM template_recipe_ingredient WHERE "recipeId" = $1 ORDER BY ordering ASC`,
602
+ [parent.id]
603
+ );
604
+ return result.rows.map((row: LocalRow) =>
605
+ mapRecipeIngredient(row, parent.locale)
606
+ );
607
+ },
608
+ instructions: async (
609
+ parent: LocalRow & { locale: 'EN' | 'FR' },
610
+ _: unknown,
611
+ ctx: ResolverContext
612
+ ) => {
613
+ const result = await ctx.db.query(
614
+ `SELECT * FROM template_recipe_instruction WHERE "recipeId" = $1 ORDER BY ordering ASC`,
615
+ [parent.id]
616
+ );
617
+ return result.rows.map((row: LocalRow) =>
618
+ mapRecipeInstruction(row, parent.locale)
619
+ );
620
+ },
621
+ },
622
+ };
623
+ }
624
+ }
625
+
626
+ function mapTaskCategory(row: LocalRow) {
627
+ return {
628
+ id: row.id,
629
+ projectId: row.projectId,
630
+ name: row.name,
631
+ color: row.color,
632
+ createdAt: row.createdAt,
633
+ updatedAt: row.updatedAt,
634
+ };
635
+ }
636
+
637
+ function mapTask(row: LocalRow) {
638
+ return {
639
+ id: row.id,
640
+ projectId: row.projectId,
641
+ categoryId: row.categoryId,
642
+ title: row.title,
643
+ description: row.description,
644
+ completed: Boolean(row.completed),
645
+ priority: row.priority ?? 'MEDIUM',
646
+ dueDate: row.dueDate,
647
+ tags: parseTags(row.tags),
648
+ createdAt: row.createdAt,
649
+ updatedAt: row.updatedAt,
650
+ };
651
+ }
652
+
653
+ function parseTags(value: LocalRow['tags']): string[] {
654
+ if (typeof value !== 'string') return [];
655
+ try {
656
+ const parsed = JSON.parse(value);
657
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
658
+ } catch {
659
+ return [];
660
+ }
661
+ }
662
+
663
+ function mapConversation(row: LocalRow) {
664
+ return {
665
+ id: row.id,
666
+ projectId: row.projectId,
667
+ name: row.name,
668
+ isGroup: Boolean(row.isGroup),
669
+ avatarUrl: row.avatarUrl,
670
+ lastMessageId: row.lastMessageId,
671
+ updatedAt: row.updatedAt,
672
+ };
673
+ }
674
+
675
+ function mapParticipant(row: LocalRow) {
676
+ return {
677
+ id: row.id,
678
+ conversationId: row.conversationId,
679
+ projectId: row.projectId,
680
+ userId: row.userId,
681
+ displayName: row.displayName,
682
+ role: row.role,
683
+ joinedAt: row.joinedAt,
684
+ lastReadAt: row.lastReadAt,
685
+ };
686
+ }
687
+
688
+ function mapMessage(row: LocalRow) {
689
+ return {
690
+ id: row.id,
691
+ conversationId: row.conversationId,
692
+ projectId: row.projectId,
693
+ senderId: row.senderId,
694
+ senderName: row.senderName,
695
+ content: row.content,
696
+ attachments: [],
697
+ status: row.status ?? 'SENT',
698
+ createdAt: row.createdAt,
699
+ updatedAt: row.updatedAt,
700
+ };
701
+ }
702
+
703
+ function mapRecipe(row: LocalRow, locale: 'EN' | 'FR') {
704
+ return {
705
+ id: row.id,
706
+ projectId: row.projectId,
707
+ slugEn: row.slugEn,
708
+ slugFr: row.slugFr,
709
+ name: locale === 'FR' ? row.nameFr : row.nameEn,
710
+ description: locale === 'FR' ? row.descriptionFr : row.descriptionEn,
711
+ heroImageUrl: row.heroImageUrl,
712
+ prepTimeMinutes: row.prepTimeMinutes ?? null,
713
+ cookTimeMinutes: row.cookTimeMinutes ?? null,
714
+ servings: row.servings ?? null,
715
+ isFavorite: Boolean(row.isFavorite),
716
+ locale,
717
+ categoryId: row.categoryId,
718
+ createdAt: row.createdAt,
719
+ updatedAt: row.updatedAt,
720
+ };
721
+ }
722
+
723
+ function mapRecipeCategory(row: LocalRow) {
724
+ return {
725
+ id: row.id,
726
+ nameEn: row.nameEn,
727
+ nameFr: row.nameFr,
728
+ icon: row.icon,
729
+ };
730
+ }
731
+
732
+ function mapRecipeIngredient(row: LocalRow, locale: 'EN' | 'FR') {
733
+ return {
734
+ id: row.id,
735
+ name: locale === 'FR' ? row.nameFr : row.nameEn,
736
+ quantity: row.quantity,
737
+ ordering: row.ordering ?? 0,
738
+ };
739
+ }
740
+
741
+ function mapRecipeInstruction(row: LocalRow, locale: 'EN' | 'FR') {
742
+ return {
743
+ id: row.id,
744
+ content: locale === 'FR' ? row.contentFr : row.contentEn,
745
+ ordering: row.ordering ?? 0,
746
+ };
747
+ }