@stonecrop/nuxt 0.13.9 → 0.13.11

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.
@@ -1,141 +1,173 @@
1
1
  /**
2
2
  * Stonecrop GraphQL Resolvers
3
3
  *
4
- * This file contains resolver implementations for the Stonecrop GraphQL schema.
5
- * Customize these resolvers to connect to your data sources.
4
+ * Uses Grafast plan resolvers — functions that return Steps (execution plans)
5
+ * rather than raw data. The key step functions used here:
6
6
  *
7
- * Grafast uses a planning-based execution model where resolvers return "steps"
8
- * (execution plans) rather than raw data. Common step functions:
9
- * - constant(value) - Returns a constant value
10
- * - context() - Accesses request context
11
- * - object({ ... }) - Creates an object with planned properties
12
- * - access($step, 'property') - Accesses a property from another step
13
- * - lambda($step, fn) - Transforms a step SYNCHRONOUSLY (no async/await!)
14
- * - loadOne($step, fn) - Batch loads data ASYNCHRONOUSLY (for DB queries)
15
- * - list([$a, $b]) - Combines multiple steps into a tuple
7
+ * - constant(value) — return a static value
8
+ * - lambda($step, fn) — transform a step SYNCHRONOUSLY (no async/await)
9
+ * - loadOne($step, fn) — batch-load data ASYNCHRONOUSLY (for DB queries)
10
+ * - object({ ... }) — group multiple steps into a single step object
16
11
  *
17
- * CRITICAL RULES:
18
- * 1. lambda functions MUST be synchronous. For async operations, use loadOne/loadMany.
19
- * 2. Plan resolver args come pre-destructured with $ prefix: (_$parent, { $argName }) => ...
20
- * 3. Never call access() on args - they're already steps!
12
+ * Data reads go through loadOne; data writes happen in server/plugins/stonecrop.ts
13
+ * via registered action handlers (start_task, complete_task, archive_project).
14
+ *
15
+ * To connect a real database, replace the imports from ./data with your
16
+ * PostGraphile setup. See: https://stonecrop.io/docs/guides/postgraphile
21
17
  */
22
18
 
23
- // Import Grafast step functions
24
- // IMPORTANT: Must import from 'grafast' not 'postgraphile/grafast' for Nitro bundling
25
- import { constant, lambda, list } from 'grafast'
19
+ import { constant, lambda, loadOne, object } from 'grafast'
20
+ import { getMeta, getAllMeta, getHandler } from '@stonecrop/graphql-middleware'
21
+ import type { ActionContext } from '@stonecrop/graphql-middleware'
22
+ import type { DoctypeMeta } from '@stonecrop/schema'
23
+ import { projects, tasks, type Project, type Task } from './data'
24
+
25
+ // ============================================================
26
+ // Formatting helpers (shape doctype metadata for the GraphQL
27
+ // response type defined in server/schema.graphql)
28
+ // ============================================================
29
+
30
+ function formatFieldMeta(field: { kind?: string; fieldname: string; fieldtype?: string; [key: string]: unknown }) {
31
+ return {
32
+ fieldname: field.fieldname,
33
+ fieldtype: field.fieldtype ?? null,
34
+ label: field.label ?? null,
35
+ required: field.required ?? false,
36
+ readOnly: field.readOnly ?? false,
37
+ options: field.options ?? null,
38
+ default: field.default ?? null,
39
+ width: field.width ?? null,
40
+ validation: field.validation ?? null,
41
+ component: field.component ?? null,
42
+ align: field.align ?? null,
43
+ edit: field.edit ?? null,
44
+ hidden: field.hidden ?? null,
45
+ mask: field.mask ?? null,
46
+ precision: field.precision ?? null,
47
+ scale: field.scale ?? null,
48
+ mode: field.mode ?? null,
49
+ }
50
+ }
51
+
52
+ function formatDoctypeMeta(meta: DoctypeMeta) {
53
+ const actions = meta.workflow?.actions
54
+ const actionList = actions
55
+ ? Object.entries(actions as Record<string, Record<string, unknown>>).map(([name, action]) => ({
56
+ name,
57
+ label: action.label ?? null,
58
+ handler: action.handler ?? null,
59
+ requiredFields: (action.requiredFields as string[]) ?? [],
60
+ allowedStates: (action.allowedStates as string[]) ?? [],
61
+ confirm: action.confirm ?? false,
62
+ args: action.args ?? null,
63
+ }))
64
+ : []
65
+ return {
66
+ name: meta.name,
67
+ slug: meta.slug ?? null,
68
+ fields: meta.fields.map(formatFieldMeta),
69
+ workflow: meta.workflow
70
+ ? {
71
+ states: meta.workflow.states ?? null,
72
+ actions: actionList,
73
+ }
74
+ : null,
75
+ inherits: meta.inherits ?? null,
76
+ }
77
+ }
78
+
79
+ // ============================================================
80
+ // Record helpers — read/write the in-memory Maps by doctype
81
+ // ============================================================
82
+
83
+ function getRecord(doctype: string, id: string): Project | Task | null {
84
+ const d = doctype.toLowerCase()
85
+ if (d === 'project') return projects.get(id) ?? null
86
+ if (d === 'task') return tasks.get(id) ?? null
87
+ return null
88
+ }
89
+
90
+ function getRecords(doctype: string, filters?: Record<string, unknown>): (Project | Task)[] {
91
+ const d = doctype.toLowerCase()
92
+ if (d === 'project') {
93
+ return Array.from(projects.values())
94
+ }
95
+ if (d === 'task') {
96
+ let all = Array.from(tasks.values())
97
+ if (filters?.projectId) {
98
+ all = all.filter(t => t.projectId === filters.projectId)
99
+ }
100
+ return all
101
+ }
102
+ return []
103
+ }
104
+
105
+ function nextId(doctype: string): string {
106
+ const d = doctype.toLowerCase()
107
+ const ids = d === 'project' ? projects.keys() : tasks.keys()
108
+ const max = Math.max(0, ...Array.from(ids, id => Number(id) || 0))
109
+ return String(max + 1)
110
+ }
26
111
 
27
- // Import Stonecrop middleware for doctype metadata
28
- // import { getMeta, getAllMeta } from '@stonecrop/graphql-middleware'
112
+ // ============================================================
113
+ // Resolvers (Grafast plan format)
114
+ // ============================================================
29
115
 
30
116
  export const resolvers = {
31
117
  Query: {
32
118
  plans: {
33
- /**
34
- * Health check endpoint
35
- * Returns a constant object with health status
36
- */
37
119
  healthCheck() {
38
- return constant({
39
- status: 'healthy',
40
- timestamp: new Date().toISOString(),
41
- version: '1.0.0',
42
- })
120
+ return constant({ status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' })
43
121
  },
44
122
 
45
- /**
46
- * Get metadata for a specific doctype
47
- * Connect this to your doctype registry or database
48
- */
49
- getMeta(_$parent, { $doctype }) {
50
- // $doctype is already a step from destructured args
51
-
52
- // TODO: Implement doctype metadata lookup
53
- // For synchronous lookups, use lambda:
54
- // return lambda($doctype, doctype => {
55
- // const meta = getMeta(doctype) // Must be synchronous!
56
- // return meta ? formatDoctypeMeta(meta) : null
57
- // })
58
- //
59
- // For async lookups, use loadOne:
60
- // return loadOne($doctype, async doctypes => {
61
- // return await Promise.all(
62
- // doctypes.map(async dt => {
63
- // const meta = await fetchMeta(dt)
64
- // return meta ? formatDoctypeMeta(meta) : null
65
- // })
66
- // )
67
- // })
123
+ getMeta(_: unknown, { $doctype }: any) {
124
+ return lambda($doctype, (doctype: unknown) => {
125
+ const meta = getMeta(doctype as string)
126
+ return meta ? formatDoctypeMeta(meta) : null
127
+ })
128
+ },
68
129
 
69
- return lambda($doctype, doctype => {
70
- console.log('getMeta called for:', doctype)
71
- return null
130
+ stonecropMeta(_: unknown, { $doctype }: any) {
131
+ return lambda($doctype, (doctype: unknown) => {
132
+ const meta = getMeta(doctype as string)
133
+ return meta ? formatDoctypeMeta(meta) : null
72
134
  })
73
135
  },
74
136
 
75
- /**
76
- * Get all registered doctype metadata
77
- * Returns an array of doctype metadata
78
- */
79
137
  stonecropAllMeta() {
80
- // TODO: Implement - return all doctype metadata
81
- // Example with graphql-middleware:
82
- // return constant(getAllMeta().map(formatDoctypeMeta))
83
-
84
- return constant([])
138
+ return constant(getAllMeta().map(formatDoctypeMeta))
85
139
  },
86
140
 
87
- /**
88
- * Get a single record by doctype and ID
89
- */
90
- stonecropRecord(_$parent, { $doctype, $id }) {
91
- // Arguments come pre-destructured as steps with $ prefix
92
-
93
- // TODO: Implement record fetching from your database
94
- // Use loadOne for async database queries:
95
- // return loadOne(list([$doctype, $id]), async pairs => {
96
- // return await Promise.all(
97
- // pairs.map(async ([doctype, id]) => {
98
- // const record = await fetchRecordFromDB(doctype, id)
99
- // return { data: record, doctype }
100
- // })
101
- // )
102
- // })
103
-
104
- return lambda(list([$doctype, $id]), ([doctype, id]) => {
105
- console.log('stonecropRecord called:', { doctype, id })
106
- return {
107
- data: null,
108
- doctype,
109
- }
141
+ stonecropRecord(_: unknown, { $doctype, $id, $options }: any) {
142
+ return loadOne(object({ doctype: $doctype, id: $id, options: $options }), async (specs: readonly any[]) => {
143
+ return specs.map(spec => ({
144
+ data: getRecord(spec.doctype, spec.id),
145
+ doctype: spec.doctype,
146
+ }))
110
147
  })
111
148
  },
112
149
 
113
- /**
114
- * Get multiple records with filtering
115
- */
116
- stonecropRecords(_$parent, { $doctype, $filters, $orderBy, $limit, $offset, $options }) {
117
- // Arguments come pre-destructured as steps with $ prefix
118
-
119
- // TODO: Implement records fetching from your database
120
- // Use loadOne for async database queries:
121
- // return loadOne(list([$doctype, $filters, $orderBy, $limit, $offset]), async queryParams => {
122
- // return await Promise.all(
123
- // queryParams.map(async ([doctype, filters, orderBy, limit, offset]) => {
124
- // const result = await queryRecordsFromDB(doctype, { filters, orderBy, limit, offset })
125
- // return { data: result.records, doctype, count: result.totalCount }
126
- // })
127
- // )
128
- // })
129
-
130
- return lambda(
131
- list([$doctype, $filters, $orderBy, $limit, $offset, $options]),
132
- ([doctype, filters, orderBy, limit, offset, options]) => {
133
- console.log('stonecropRecords called:', { doctype, filters, orderBy, limit, offset, options })
134
- return {
135
- data: [],
136
- doctype,
137
- count: 0,
138
- }
150
+ stonecropRecords(_: unknown, { $doctype, $filters, $orderBy, $limit, $offset, $options }: any) {
151
+ return loadOne(
152
+ object({
153
+ doctype: $doctype,
154
+ filters: $filters,
155
+ orderBy: $orderBy,
156
+ limit: $limit,
157
+ offset: $offset,
158
+ options: $options,
159
+ }),
160
+ async (specs: readonly any[]) => {
161
+ return specs.map(spec => {
162
+ const all = getRecords(spec.doctype, spec.filters ?? {})
163
+ const offset = spec.offset ?? 0
164
+ const limit = spec.limit ?? 100
165
+ return {
166
+ data: all.slice(offset, offset + limit),
167
+ doctype: spec.doctype,
168
+ count: all.length,
169
+ }
170
+ })
139
171
  }
140
172
  )
141
173
  },
@@ -144,109 +176,94 @@ export const resolvers = {
144
176
 
145
177
  Mutation: {
146
178
  plans: {
147
- /**
148
- * Execute a doctype action (workflow actions like activate, archive, etc.)
149
- */
150
- stonecropAction(_$parent, { $doctype, $action, $args }) {
151
- // Arguments come pre-destructured as steps with $ prefix
179
+ stonecropAction(_: unknown, { $doctype, $action, $args: $actionArgs }: any) {
180
+ return loadOne(
181
+ object({ doctype: $doctype, action: $action, actionArgs: $actionArgs }),
182
+ async (specs: readonly any[]) => {
183
+ return Promise.all(
184
+ specs.map(async spec => {
185
+ const meta = getMeta(spec.doctype)
186
+ if (!meta) return { success: false, data: null, error: `Unknown doctype: ${spec.doctype}` }
152
187
 
153
- // TODO: Implement action execution
154
- // Use loadOne for async operations:
155
- // return loadOne(list([$doctype, $action, $args]), async actionParams => {
156
- // return await Promise.all(
157
- // actionParams.map(async ([doctype, action, actionArgs]) => {
158
- // const result = await executeAction(doctype, action, actionArgs)
159
- // return { success: result.success, data: result.data, error: result.error }
160
- // })
161
- // )
162
- // })
188
+ const actionDef = meta.workflow?.actions?.[spec.action]
189
+ if (!actionDef) return { success: false, data: null, error: `Unknown action: ${spec.action}` }
163
190
 
164
- return lambda(list([$doctype, $action, $args]), ([doctype, action, actionArgs]) => {
165
- console.log('stonecropAction called:', { doctype, action, args: actionArgs })
166
- return {
167
- success: true,
168
- data: null,
169
- error: null,
170
- }
171
- })
172
- },
191
+ const handler = getHandler(actionDef.handler)
192
+ if (!handler)
193
+ return { success: false, data: null, error: `Handler not registered: ${actionDef.handler}` }
173
194
 
174
- /**
175
- * Create a new record
176
- */
177
- stonecropCreate(_$parent, { $doctype, $input }) {
178
- // Arguments come pre-destructured as steps with $ prefix
195
+ // Pass doctype metadata via ActionContext. The handler in server/plugins/stonecrop.ts
196
+ // imports and mutates the data Maps directly — it does not need additional context here.
197
+ // In a PostGraphile setup, context.pgClient would provide the database connection instead.
198
+ const actionContext: ActionContext = { doctype: meta }
179
199
 
180
- // TODO: Implement record creation
181
- // Use loadOne for async database operations:
182
- // return loadOne(list([$doctype, $input]), async createParams => {
183
- // return await Promise.all(
184
- // createParams.map(async ([doctype, input]) => {
185
- // const newRecord = await createRecordInDB(doctype, input)
186
- // return { data: newRecord, doctype }
187
- // })
188
- // )
189
- // })
190
-
191
- return lambda(list([$doctype, $input]), ([doctype, input]) => {
192
- console.log('stonecropCreate called:', { doctype, input })
193
- return {
194
- data: { id: 'new-id', ...input },
195
- doctype,
200
+ try {
201
+ const result = await handler(spec.actionArgs ?? [], actionContext)
202
+ return { success: true, data: result, error: null }
203
+ } catch (err) {
204
+ return { success: false, data: null, error: err instanceof Error ? err.message : String(err) }
205
+ }
206
+ })
207
+ )
196
208
  }
197
- })
209
+ )
198
210
  },
199
211
 
200
- /**
201
- * Update an existing record
202
- */
203
- stonecropUpdate(_$parent, { $doctype, $id, $patch }) {
204
- // Arguments come pre-destructured as steps with $ prefix
205
-
206
- // TODO: Implement record update
207
- // Use loadOne for async database operations:
208
- // return loadOne(list([$doctype, $id, $patch]), async updateParams => {
209
- // return await Promise.all(
210
- // updateParams.map(async ([doctype, id, patch]) => {
211
- // const updatedRecord = await updateRecordInDB(doctype, id, patch)
212
- // return { data: updatedRecord, doctype }
213
- // })
214
- // )
215
- // })
216
-
217
- return lambda(list([$doctype, $id, $patch]), ([doctype, id, patch]) => {
218
- console.log('stonecropUpdate called:', { doctype, id, patch })
219
- return {
220
- data: { id, ...patch },
221
- doctype,
222
- }
212
+ stonecropCreate(_: unknown, { $doctype, $input }: any) {
213
+ return loadOne(object({ doctype: $doctype, input: $input }), async (specs: readonly any[]) => {
214
+ return specs.map(spec => {
215
+ const d = spec.doctype.toLowerCase()
216
+ const id = nextId(d)
217
+ const now = new Date().toISOString()
218
+ if (d === 'project') {
219
+ const record: Project = { id, createdAt: now, status: 'Active', description: '', ...spec.input }
220
+ projects.set(id, record)
221
+ return { data: record, doctype: spec.doctype }
222
+ }
223
+ if (d === 'task') {
224
+ const record: Task = { id, createdAt: now, status: 'Todo', description: '', dueDate: null, ...spec.input }
225
+ tasks.set(id, record)
226
+ return { data: record, doctype: spec.doctype }
227
+ }
228
+ return { data: null, doctype: spec.doctype }
229
+ })
223
230
  })
224
231
  },
225
232
 
226
- /**
227
- * Delete a record
228
- */
229
- stonecropDelete(_$parent, { $doctype, $id }) {
230
- // Arguments come pre-destructured as steps with $ prefix
231
-
232
- // TODO: Implement record deletion
233
- // Use loadOne for async database operations:
234
- // return loadOne(list([$doctype, $id]), async deleteParams => {
235
- // return await Promise.all(
236
- // deleteParams.map(async ([doctype, id]) => {
237
- // await deleteRecordFromDB(doctype, id)
238
- // return { success: true, data: { id }, error: null }
239
- // })
240
- // )
241
- // })
233
+ stonecropUpdate(_: unknown, { $doctype, $id, $patch }: any) {
234
+ return loadOne(object({ doctype: $doctype, id: $id, patch: $patch }), async (specs: readonly any[]) => {
235
+ return specs.map(spec => {
236
+ const d = spec.doctype.toLowerCase()
237
+ const existing = getRecord(d, spec.id)
238
+ if (!existing) return null
239
+ if (d === 'project') {
240
+ const updated = { ...existing, ...spec.patch } as Project
241
+ projects.set(spec.id, updated)
242
+ return { data: updated, doctype: spec.doctype }
243
+ }
244
+ if (d === 'task') {
245
+ const updated = { ...existing, ...spec.patch } as Task
246
+ tasks.set(spec.id, updated)
247
+ return { data: updated, doctype: spec.doctype }
248
+ }
249
+ return null
250
+ })
251
+ })
252
+ },
242
253
 
243
- return lambda(list([$doctype, $id]), ([doctype, id]) => {
244
- console.log('stonecropDelete called:', { doctype, id })
245
- return {
246
- success: true,
247
- data: { id },
248
- error: null,
249
- }
254
+ stonecropDelete(_: unknown, { $doctype, $id }: any) {
255
+ return loadOne(object({ doctype: $doctype, id: $id }), async (specs: readonly any[]) => {
256
+ return specs.map(spec => {
257
+ const d = spec.doctype.toLowerCase()
258
+ let deleted = false
259
+ if (d === 'project') deleted = projects.delete(spec.id)
260
+ else if (d === 'task') deleted = tasks.delete(spec.id)
261
+ return {
262
+ success: deleted,
263
+ data: deleted ? { id: spec.id } : null,
264
+ error: deleted ? null : 'Record not found',
265
+ }
266
+ })
250
267
  })
251
268
  },
252
269
  },
@@ -20,6 +20,27 @@ type StonecropField {
20
20
  default: JSON
21
21
  width: String
22
22
  validation: JSON
23
+ component: String
24
+ align: String
25
+ edit: Boolean
26
+ hidden: Boolean
27
+ mask: String
28
+ precision: Int
29
+ scale: Int
30
+ mode: String
31
+ }
32
+
33
+ """
34
+ A single workflow action definition
35
+ """
36
+ type WorkflowAction {
37
+ name: String!
38
+ label: String
39
+ handler: String
40
+ requiredFields: [String!]
41
+ allowedStates: [String!]
42
+ confirm: Boolean
43
+ args: JSON
23
44
  }
24
45
 
25
46
  """
@@ -27,7 +48,7 @@ Workflow metadata with states and actions
27
48
  """
28
49
  type WorkflowMeta {
29
50
  states: [String!]
30
- actions: JSON
51
+ actions: [WorkflowAction!]
31
52
  }
32
53
 
33
54
  """
@@ -54,6 +75,7 @@ type HealthStatus {
54
75
  type RecordResult {
55
76
  data: JSON
56
77
  doctype: String!
78
+ unknownLinks: [String!]
57
79
  }
58
80
 
59
81
  type RecordsResult {
@@ -83,6 +105,11 @@ type Query {
83
105
  """
84
106
  getMeta(doctype: String!): DoctypeMeta
85
107
 
108
+ """
109
+ Get metadata for a doctype (used by StonecropClient)
110
+ """
111
+ stonecropMeta(doctype: String!): DoctypeMeta
112
+
86
113
  """
87
114
  Get all registered doctype metadata
88
115
  """
@@ -91,7 +118,7 @@ type Query {
91
118
  """
92
119
  Get a single record by doctype and ID
93
120
  """
94
- stonecropRecord(doctype: String!, id: String!): RecordResult
121
+ stonecropRecord(doctype: String!, id: String!, options: JSON): RecordResult
95
122
 
96
123
  """
97
124
  Get multiple records with optional filtering
@@ -0,0 +1,42 @@
1
+ import { StonecropClient } from '@stonecrop/graphql-client'
2
+ import { Doctype } from '@stonecrop/stonecrop'
3
+
4
+ import { doctypeMap } from '~/composables/useDoctypes'
5
+
6
+ export default defineNuxtPlugin({
7
+ name: 'stonecrop-client',
8
+ dependsOn: ['stonecrop'],
9
+ setup() {
10
+ const { registerClient, registerMeta, registry } = useStonecropSetup()
11
+
12
+ const client = new StonecropClient({
13
+ endpoint: '/graphql/',
14
+ })
15
+
16
+ registerClient(client)
17
+
18
+ registerMeta(async routeContext => {
19
+ const slug = routeContext.segments?.[0] ?? ''
20
+ if (!slug) throw new Error('Cannot resolve doctype from route context')
21
+
22
+ const localDoctype = doctypeMap.get(slug)
23
+ if (!localDoctype) throw new Error(`No doctype registered for slug: ${slug}`)
24
+
25
+ return Doctype.fromObject(localDoctype)
26
+ })
27
+
28
+ for (const [slug, doctypeConfig] of doctypeMap.entries()) {
29
+ const doctypeInstance = Doctype.fromObject(doctypeConfig)
30
+ registry!.addDoctype(doctypeInstance)
31
+ if (slug !== doctypeInstance.slug) {
32
+ registry!.registry[slug] = doctypeInstance
33
+ }
34
+ }
35
+
36
+ return {
37
+ provide: {
38
+ stonecropClient: client,
39
+ },
40
+ }
41
+ },
42
+ })
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Stonecrop Server Plugin
3
+ *
4
+ * Runs at server startup (Nitro plugin) to:
5
+ * 1. Load doctype definitions from the doctypes/ directory
6
+ * 2. Register built-in action handlers (validation, etc.)
7
+ * 3. Register custom workflow action handlers
8
+ *
9
+ * Action handlers receive (args, context) where args is the array sent by the
10
+ * client — the app's frontend (app/composables/useDoctypes.ts) sends a single
11
+ * `{ id, data? }` object per action — and context.doctype is the DoctypeMeta
12
+ * for the doctype being acted upon. For this in-memory setup, data is imported
13
+ * directly from server/data.ts. In production with PostGraphile, context.pgClient
14
+ * provides an active database connection instead — see:
15
+ * https://stonecrop.io/docs/guides/postgraphile
16
+ */
17
+
18
+ import { resolve } from 'node:path'
19
+ import { clearRegistry, loadDoctypes, registerBuiltinHandlers, registerHandler } from '@stonecrop/graphql-middleware'
20
+ import { projects, tasks, type Project, type Task } from '../data'
21
+
22
+ /** Action argument shape sent by the frontend (see runDoctypeAction in useDoctypes.ts) */
23
+ type ActionArgs = [{ id: string; data?: Record<string, unknown> }]
24
+
25
+ export default defineNitroPlugin(async () => {
26
+ clearRegistry()
27
+
28
+ const doctypesDir = resolve(process.cwd(), 'doctypes')
29
+ loadDoctypes(doctypesDir, { continueOnError: true })
30
+
31
+ registerBuiltinHandlers()
32
+
33
+ // Persist form edits to a Project
34
+ registerHandler('project:save', async (args: unknown[]) => {
35
+ const [{ id, data }] = args as ActionArgs
36
+ const project = projects.get(id)
37
+ if (!project) throw new Error(`Project not found: ${id}`)
38
+ const updated = { ...project, ...data, id } as Project
39
+ projects.set(id, updated)
40
+ return updated
41
+ })
42
+
43
+ // Persist form edits to a Task
44
+ registerHandler('task:save', async (args: unknown[]) => {
45
+ const [{ id, data }] = args as ActionArgs
46
+ const task = tasks.get(id)
47
+ if (!task) throw new Error(`Task not found: ${id}`)
48
+ const updated = { ...task, ...data, id } as Task
49
+ tasks.set(id, updated)
50
+ return updated
51
+ })
52
+
53
+ // Transition a Task from Todo → In Progress
54
+ registerHandler('start_task', async (args: unknown[]) => {
55
+ const [{ id }] = args as ActionArgs
56
+ const task = tasks.get(id)
57
+ if (!task) throw new Error(`Task not found: ${id}`)
58
+ const updated = { ...task, status: 'In Progress' as const }
59
+ tasks.set(id, updated)
60
+ return updated
61
+ })
62
+
63
+ // Transition a Task from In Progress → Done
64
+ registerHandler('complete_task', async (args: unknown[]) => {
65
+ const [{ id }] = args as ActionArgs
66
+ const task = tasks.get(id)
67
+ if (!task) throw new Error(`Task not found: ${id}`)
68
+ const updated = { ...task, status: 'Done' as const }
69
+ tasks.set(id, updated)
70
+ return updated
71
+ })
72
+
73
+ // Transition a Project from Active → Archived
74
+ registerHandler('archive_project', async (args: unknown[]) => {
75
+ const [{ id }] = args as ActionArgs
76
+ const project = projects.get(id)
77
+ if (!project) throw new Error(`Project not found: ${id}`)
78
+ const updated = { ...project, status: 'Archived' as const }
79
+ projects.set(id, updated)
80
+ return updated
81
+ })
82
+ })