ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
/**
|
|
* v1 async-operation lifecycle helpers.
|
|
*
|
|
* Substrate: the `operations` table (separate from `pending_operations`).
|
|
*
|
|
* Design:
|
|
* - POST handlers that start a long-running job call `startOperation()`
|
|
* to insert a row (status='running', started_at=now) and return its id.
|
|
* - The handler then runs the work (synchronously in Phase 4 PR-2; a
|
|
* future cron worker can take over by picking up `queued` rows).
|
|
* - On success: `completeOperation(id, result)`. On failure:
|
|
* `failOperation(id, error)`. Both stamp `completed_at`.
|
|
* - The 202 envelope is built by the helper, so call sites only need to
|
|
* return what it gives back.
|
|
*
|
|
* The shape stays stable when (or if) we move to true out-of-band processing:
|
|
* the POST simply leaves the row at status='queued' for a worker to pick up,
|
|
* the response is identical, and the GET poll endpoint surfaces progress as
|
|
* the worker updates it.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { Logger } from '@/lib/logger'
|
|
|
|
export type OperationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
|
|
|
export interface OperationProgress {
|
|
/** Optional human-readable phase label (e.g. 'parsing', 'committing'). */
|
|
phase?: string
|
|
/** Optional unit counters for percent calculation client-side. */
|
|
current?: number
|
|
total?: number
|
|
/** Free-form additional fields: kept under jsonb so additions don't migrate. */
|
|
[k: string]: unknown
|
|
}
|
|
|
|
export interface OperationRow {
|
|
id: string
|
|
company_id: string
|
|
user_id: string
|
|
operation_type: string
|
|
status: OperationStatus
|
|
started_at: string | null
|
|
completed_at: string | null
|
|
params: Record<string, unknown>
|
|
progress: OperationProgress
|
|
result: unknown
|
|
error: { code?: string; message?: string; details?: unknown } | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
/**
|
|
* Insert a new operation row in `running` status (start_at=now). Returns the
|
|
* id the POST handler should report back. The caller continues with the work
|
|
* and then resolves via `completeOperation` / `failOperation`.
|
|
*
|
|
* For true async dispatch (future cron worker), pass `status='queued'` and
|
|
* leave `started_at` null: the worker stamps it when it picks the row up.
|
|
*/
|
|
export async function startOperation(
|
|
supabase: SupabaseClient,
|
|
args: {
|
|
companyId: string
|
|
userId: string
|
|
operationType: string
|
|
params?: Record<string, unknown>
|
|
/** Default `'running'` for inline execution; `'queued'` for worker dispatch. */
|
|
initialStatus?: Extract<OperationStatus, 'queued' | 'running'>
|
|
},
|
|
log: Logger,
|
|
): Promise<{ id: string }> {
|
|
const initialStatus = args.initialStatus ?? 'running'
|
|
const startedAt = initialStatus === 'running' ? new Date().toISOString() : null
|
|
|
|
const { data, error } = await supabase
|
|
.from('operations')
|
|
.insert({
|
|
company_id: args.companyId,
|
|
user_id: args.userId,
|
|
operation_type: args.operationType,
|
|
status: initialStatus,
|
|
started_at: startedAt,
|
|
params: args.params ?? {},
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
log.error('startOperation insert failed', error as Error, {
|
|
companyId: args.companyId,
|
|
operationType: args.operationType,
|
|
})
|
|
throw new Error('Failed to record operation start')
|
|
}
|
|
return { id: (data as { id: string }).id }
|
|
}
|
|
|
|
/**
|
|
* Mark an operation as succeeded. Stamps `completed_at` and persists `result`.
|
|
* Best-effort: a failure to record the success doesn't roll back the work
|
|
* (the work already committed to the DB via whatever engine call ran).
|
|
*/
|
|
export async function completeOperation(
|
|
supabase: SupabaseClient,
|
|
args: { id: string; result: unknown; finalProgress?: OperationProgress },
|
|
log: Logger,
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('operations')
|
|
.update({
|
|
status: 'succeeded',
|
|
completed_at: new Date().toISOString(),
|
|
result: args.result,
|
|
...(args.finalProgress ? { progress: args.finalProgress } : {}),
|
|
})
|
|
.eq('id', args.id)
|
|
if (error) {
|
|
log.warn('completeOperation update failed', { operationId: args.id, errorCode: error.code })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark an operation as failed. Stamps `completed_at` and persists `error`.
|
|
* The caller has already converted the underlying error into a structured
|
|
* code+message envelope.
|
|
*/
|
|
export async function failOperation(
|
|
supabase: SupabaseClient,
|
|
args: {
|
|
id: string
|
|
error: { code: string; message: string; details?: unknown }
|
|
finalProgress?: OperationProgress
|
|
},
|
|
log: Logger,
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('operations')
|
|
.update({
|
|
status: 'failed',
|
|
completed_at: new Date().toISOString(),
|
|
error: args.error,
|
|
...(args.finalProgress ? { progress: args.finalProgress } : {}),
|
|
})
|
|
.eq('id', args.id)
|
|
if (error) {
|
|
log.warn('failOperation update failed', { operationId: args.id, errorCode: error.code })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update progress on a running operation. Non-blocking: a write failure is
|
|
* logged but not raised: the work continues regardless.
|
|
*/
|
|
export async function updateOperationProgress(
|
|
supabase: SupabaseClient,
|
|
args: { id: string; progress: OperationProgress },
|
|
log: Logger,
|
|
): Promise<void> {
|
|
const { error } = await supabase
|
|
.from('operations')
|
|
.update({ progress: args.progress })
|
|
.eq('id', args.id)
|
|
if (error) {
|
|
log.warn('updateOperationProgress failed', { operationId: args.id, errorCode: error.code })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read an operation row, scoped to the caller's company. Returns null when
|
|
* the id is not found (or belongs to another company; RLS already excludes
|
|
* those, but the explicit `.eq('company_id')` keeps the contract clear).
|
|
*/
|
|
export async function getOperation(
|
|
supabase: SupabaseClient,
|
|
args: { id: string; companyId: string },
|
|
): Promise<OperationRow | null> {
|
|
const { data, error } = await supabase
|
|
.from('operations')
|
|
.select('id, company_id, user_id, operation_type, status, started_at, completed_at, params, progress, result, error, created_at, updated_at')
|
|
.eq('id', args.id)
|
|
.eq('company_id', args.companyId)
|
|
.maybeSingle()
|
|
if (error || !data) return null
|
|
return data as OperationRow
|
|
}
|