suemo 0.0.1

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.
package/src/sync.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { createNodeEngines } from '@surrealdb/node'
2
+ import { createHash } from 'node:crypto'
3
+ import { createRemoteEngines, Surreal } from 'surrealdb'
4
+ import type { SurrealTarget } from './config.ts'
5
+ import { getLogger } from './logger.ts'
6
+ import type { SyncResult } from './types.ts'
7
+
8
+ const log = getLogger(['suemo', 'sync'])
9
+
10
+ const BATCH_SIZE = 500
11
+
12
+ function remoteKey(target: SurrealTarget): string {
13
+ return createHash('sha1')
14
+ .update(`${target.url}|${target.namespace}|${target.database}`)
15
+ .digest('hex')
16
+ .slice(0, 16)
17
+ }
18
+
19
+ export async function syncTo(
20
+ sourceDb: Surreal,
21
+ target: SurrealTarget,
22
+ opts: { dryRun?: boolean } = {},
23
+ ): Promise<SyncResult> {
24
+ log.info('syncTo()', { target: `${target.url}/${target.namespace}/${target.database}`, dryRun: opts.dryRun })
25
+
26
+ // Load cursor (last successfully synced created_at)
27
+ const key = remoteKey(target)
28
+ const cursorResult = await sourceDb.query<[{ cursor: string }[]]>(
29
+ `
30
+ SELECT cursor FROM sync_cursor WHERE remote_key = $key LIMIT 1
31
+ `,
32
+ { key },
33
+ )
34
+ const cursor = cursorResult[0]?.[0]?.cursor ?? '1970-01-01T00:00:00Z'
35
+
36
+ log.info('Sync cursor', { cursor })
37
+
38
+ // Connect to remote
39
+ const remoteDb = new Surreal({
40
+ engines: {
41
+ ...createRemoteEngines(),
42
+ ...createNodeEngines(),
43
+ },
44
+ })
45
+ await remoteDb.connect(target.url, {
46
+ namespace: target.namespace,
47
+ database: target.database,
48
+ authentication: () => ({
49
+ username: target.auth.user,
50
+ password: target.auth.pass,
51
+ }),
52
+ })
53
+
54
+ let pushed = 0
55
+ let skipped = 0
56
+ let errors = 0
57
+ let latestCreatedAt = cursor
58
+
59
+ try {
60
+ let offset = 0
61
+
62
+ while (true) {
63
+ const batch = await sourceDb.query<[{ id: string; created_at: string; [k: string]: unknown }[]]>(
64
+ `
65
+ SELECT * FROM memory
66
+ WHERE created_at > <datetime>$cursor
67
+ ORDER BY created_at ASC
68
+ LIMIT $limit START $offset
69
+ `,
70
+ { cursor, limit: BATCH_SIZE, offset },
71
+ )
72
+
73
+ const rows = batch[0] ?? []
74
+ if (rows.length === 0) break
75
+
76
+ for (const row of rows) {
77
+ if (opts.dryRun) {
78
+ log.debug('dry-run: would push', { id: row['id'] })
79
+ skipped++
80
+ } else {
81
+ try {
82
+ // INSERT IGNORE: if the ID already exists on remote, skip it
83
+ await remoteDb.query('INSERT IGNORE INTO memory $row', { row })
84
+ pushed++
85
+ } catch (e) {
86
+ log.error('Failed to push record', { id: row['id'], error: String(e) })
87
+ errors++
88
+ }
89
+ }
90
+ if (row['created_at'] && row['created_at'] > latestCreatedAt) {
91
+ latestCreatedAt = row['created_at']
92
+ }
93
+ }
94
+
95
+ offset += rows.length
96
+ if (rows.length < BATCH_SIZE) break
97
+ }
98
+
99
+ // Also sync relations
100
+ // (similar batching logic; omitted for brevity — same pattern with relates_to table)
101
+
102
+ if (!opts.dryRun && pushed > 0) {
103
+ // Advance cursor
104
+ await sourceDb.query(
105
+ `
106
+ UPSERT sync_cursor SET
107
+ remote_key = $key,
108
+ cursor = <datetime>$cursor,
109
+ last_synced = time::now()
110
+ `,
111
+ { key, cursor: latestCreatedAt },
112
+ )
113
+ }
114
+ } finally {
115
+ await remoteDb.close()
116
+ }
117
+
118
+ log.info('syncTo() complete', { pushed, skipped, errors })
119
+ return { pushed, skipped, errors, cursor: latestCreatedAt }
120
+ }
package/src/types.ts ADDED
@@ -0,0 +1,144 @@
1
+ // Zod v4 (^4.3.6): import from "zod" root — package root now exports v4.
2
+ // Key v4 behavior relevant here: .default() always applies even for absent keys.
3
+ import { z } from 'zod'
4
+
5
+ // ── Memory kinds ─────────────────────────────────────────────────────────────
6
+ export const MemoryKindSchema = z.enum([
7
+ 'observation',
8
+ 'belief',
9
+ 'question',
10
+ 'hypothesis',
11
+ 'goal',
12
+ ])
13
+ export type MemoryKind = z.infer<typeof MemoryKindSchema>
14
+
15
+ // ── Relation kinds ───────────────────────────────────────────────────────────
16
+ export const RelationKindSchema = z.enum([
17
+ 'supports',
18
+ 'contradicts',
19
+ 'derived_from',
20
+ 'caused_by',
21
+ 'similar_to',
22
+ 'updates',
23
+ 'sequential',
24
+ ])
25
+ export type RelationKind = z.infer<typeof RelationKindSchema>
26
+
27
+ // ── Core memory node (matches DB schema exactly) ─────────────────────────────
28
+ export const MemoryNodeSchema = z.object({
29
+ id: z.string(),
30
+ kind: MemoryKindSchema,
31
+ content: z.string(),
32
+ summary: z.string().nullable(),
33
+ tags: z.array(z.string()),
34
+ scope: z.string().nullable(),
35
+ embedding: z.array(z.number()),
36
+ confidence: z.number().min(0).max(1),
37
+ salience: z.number().min(0).max(1),
38
+ valid_from: z.iso.datetime(),
39
+ valid_until: z.iso.datetime().nullable(),
40
+ source: z.string().nullable(),
41
+ created_at: z.iso.datetime(),
42
+ updated_at: z.iso.datetime(),
43
+ consolidated: z.boolean(),
44
+ consolidated_into: z.string().nullable(), // record ID string
45
+ fsrs_stability: z.number().nullable(),
46
+ fsrs_difficulty: z.number().nullable(),
47
+ fsrs_next_review: z.iso.datetime().nullable(),
48
+ })
49
+ export type MemoryNode = z.infer<typeof MemoryNodeSchema>
50
+
51
+ // ── Relation edge ─────────────────────────────────────────────────────────────
52
+ export const RelationSchema = z.object({
53
+ id: z.string(),
54
+ in: z.string(), // memory record ID
55
+ out: z.string(), // memory record ID
56
+ kind: RelationKindSchema,
57
+ strength: z.number().min(0).max(1),
58
+ valid_from: z.iso.datetime(),
59
+ valid_until: z.iso.datetime().nullable(),
60
+ created_at: z.iso.datetime(),
61
+ })
62
+ export type Relation = z.infer<typeof RelationSchema>
63
+
64
+ // ── Episode ───────────────────────────────────────────────────────────────────
65
+ export const EpisodeSchema = z.object({
66
+ id: z.string(),
67
+ session_id: z.string(),
68
+ started_at: z.iso.datetime(),
69
+ ended_at: z.iso.datetime().nullable(),
70
+ summary: z.string().nullable(),
71
+ memory_ids: z.array(z.string()),
72
+ })
73
+ export type Episode = z.infer<typeof EpisodeSchema>
74
+
75
+ // ── Consolidation run log ────────────────────────────────────────────────────
76
+ export const ConsolidationRunSchema = z.object({
77
+ id: z.string(),
78
+ started_at: z.iso.datetime(),
79
+ completed_at: z.iso.datetime().nullable(),
80
+ phase: z.enum(['nrem', 'rem', 'full']),
81
+ nodes_in: z.number().int(),
82
+ nodes_out: z.number().int(),
83
+ status: z.enum(['running', 'done', 'failed']),
84
+ error: z.string().nullable(),
85
+ })
86
+ export type ConsolidationRun = z.infer<typeof ConsolidationRunSchema>
87
+
88
+ // ── observe() input ───────────────────────────────────────────────────────────
89
+ // NOTE: no valid_from, valid_until — system-managed only, never in inputs
90
+ // Zod v4: .default() on a field applies the default even when the key is absent
91
+ // from input — this is the intended behavior for all optional fields here.
92
+ export const ObserveInputSchema = z.object({
93
+ content: z.string().min(1),
94
+ kind: MemoryKindSchema.default('observation').optional(),
95
+ tags: z.array(z.string()).default([]).optional(),
96
+ scope: z.string().optional(),
97
+ source: z.string().optional(),
98
+ confidence: z.number().min(0).max(1).default(1.0).optional(),
99
+ })
100
+ export type ObserveInput = z.infer<typeof ObserveInputSchema>
101
+
102
+ // ── query() input ─────────────────────────────────────────────────────────────
103
+ export const QueryInputSchema = z.object({
104
+ input: z.string().min(1),
105
+ scope: z.string().optional(),
106
+ kind: z.array(MemoryKindSchema).optional(),
107
+ topK: z.number().int().min(1).max(50).default(5).optional(),
108
+ activeOnly: z.boolean().default(true).optional(),
109
+ strategies: z
110
+ .array(z.enum(['vector', 'bm25', 'graph', 'temporal']))
111
+ .default(['vector', 'bm25', 'graph'])
112
+ .optional(),
113
+ })
114
+ export type QueryInput = z.infer<typeof QueryInputSchema>
115
+
116
+ // ── Health report ─────────────────────────────────────────────────────────────
117
+ export const HealthReportSchema = z.object({
118
+ nodes: z.object({
119
+ total: z.number(),
120
+ active: z.number(),
121
+ consolidated: z.number(),
122
+ by_kind: z.record(z.string(), z.number()),
123
+ by_scope: z.record(z.string(), z.number()),
124
+ }),
125
+ relations: z.number(),
126
+ goals_active: z.number(),
127
+ fsrs_due: z.number(),
128
+ last_consolidation: ConsolidationRunSchema.nullable(),
129
+ version_check: z.object({
130
+ surreal_version: z.string(),
131
+ surrealkv: z.boolean(),
132
+ retention_ok: z.boolean(), // SURREAL_DATASTORE_RETENTION >= 90d on the server
133
+ }),
134
+ })
135
+ export type HealthReport = z.infer<typeof HealthReportSchema>
136
+
137
+ // ── Sync result ───────────────────────────────────────────────────────────────
138
+ export const SyncResultSchema = z.object({
139
+ pushed: z.number(),
140
+ skipped: z.number(),
141
+ errors: z.number(),
142
+ cursor: z.iso.datetime(),
143
+ })
144
+ export type SyncResult = z.infer<typeof SyncResultSchema>