@mepuka/skygent 0.2.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 (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,200 @@
1
+ import { Clock, Context, Effect, Layer, Option, Random, Schema, SynchronizedRef } from "effect";
2
+ import type * as SqlClient from "@effect/sql/SqlClient";
3
+ import * as SqlSchema from "@effect/sql/SqlSchema";
4
+ import { StoreIoError } from "../domain/errors.js";
5
+ import { type EventLogEntry, PostEvent, PostEventRecord } from "../domain/events.js";
6
+ import { EventId, EventSeq, PostUri } from "../domain/primitives.js";
7
+ import type { StorePath } from "../domain/primitives.js";
8
+ import type { StoreRef } from "../domain/store.js";
9
+ import { StoreDb } from "./store-db.js";
10
+
11
+ const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
12
+ const MAX_ULID_TIME = 0xffff_ffff_ffff;
13
+
14
+ class UlidTimeError extends Schema.TaggedError<UlidTimeError>()("UlidTimeError", {
15
+ time: Schema.String,
16
+ message: Schema.String
17
+ }) {}
18
+
19
+ const encodeTime = (time: number) => {
20
+ if (!Number.isFinite(time) || time < 0 || time > MAX_ULID_TIME) {
21
+ return Effect.fail(
22
+ UlidTimeError.make({
23
+ time: String(time),
24
+ message: `ULID time out of range: ${time}`
25
+ })
26
+ );
27
+ }
28
+ return Effect.sync(() => {
29
+ let value = BigInt(Math.trunc(time));
30
+ let output = "";
31
+ for (let i = 0; i < 10; i += 1) {
32
+ const mod = Number(value % 32n);
33
+ output = `${ULID_ALPHABET[mod]}${output}`;
34
+ value = value / 32n;
35
+ }
36
+ return output;
37
+ });
38
+ };
39
+
40
+ const encodeRandomDigits = (digits: ReadonlyArray<number>) =>
41
+ digits.map((digit) => ULID_ALPHABET[digit]).join("");
42
+
43
+ const incrementRandomDigits = (digits: ReadonlyArray<number>) => {
44
+ const next = digits.slice();
45
+ for (let i = next.length - 1; i >= 0; i -= 1) {
46
+ const value = next[i];
47
+ if (typeof value !== "number") {
48
+ continue;
49
+ }
50
+ if (value === 31) {
51
+ next[i] = 0;
52
+ if (i === 0) {
53
+ return { digits: next, overflow: true } as const;
54
+ }
55
+ } else {
56
+ next[i] = value + 1;
57
+ return { digits: next, overflow: false } as const;
58
+ }
59
+ }
60
+ return { digits: next, overflow: true } as const;
61
+ };
62
+
63
+ const eventLogInsertRow = Schema.Struct({
64
+ event_id: EventId,
65
+ event_type: Schema.String,
66
+ post_uri: PostUri,
67
+ payload_json: Schema.String,
68
+ created_at: Schema.String,
69
+ source: Schema.String
70
+ });
71
+
72
+ const eventLogSeqRow = Schema.Struct({
73
+ event_seq: EventSeq
74
+ });
75
+
76
+ const toStoreIoError = (path: StorePath) => (cause: unknown) =>
77
+ StoreIoError.make({ path, cause });
78
+
79
+ export class StoreWriter extends Context.Tag("@skygent/StoreWriter")<
80
+ StoreWriter,
81
+ {
82
+ readonly append: (
83
+ store: StoreRef,
84
+ event: PostEvent
85
+ ) => Effect.Effect<EventLogEntry, StoreIoError>;
86
+ readonly appendWithClient: (
87
+ client: SqlClient.SqlClient,
88
+ event: PostEvent
89
+ ) => Effect.Effect<EventLogEntry, unknown>;
90
+ }
91
+ >() {
92
+ static readonly layer = Layer.effect(
93
+ StoreWriter,
94
+ Effect.gen(function* () {
95
+ const storeDb = yield* StoreDb;
96
+ const idState = yield* SynchronizedRef.make({
97
+ lastTime: 0,
98
+ lastRandom: [] as ReadonlyArray<number>
99
+ });
100
+
101
+ const nextRandomDigits = () =>
102
+ Effect.forEach(
103
+ Array.from({ length: 16 }),
104
+ () => Random.nextIntBetween(0, 32)
105
+ );
106
+
107
+ const generateEventId = Effect.fn("StoreWriter.generateEventId")(() =>
108
+ SynchronizedRef.modifyEffect(idState, (state) =>
109
+ Effect.gen(function* () {
110
+ const now = yield* Clock.currentTimeMillis;
111
+ let time = state.lastTime;
112
+ let digits = state.lastRandom;
113
+
114
+ if (digits.length === 0 || now > state.lastTime) {
115
+ time = now;
116
+ digits = yield* nextRandomDigits();
117
+ } else {
118
+ const incremented = incrementRandomDigits(digits);
119
+ if (incremented.overflow) {
120
+ time = state.lastTime + 1;
121
+ digits = yield* nextRandomDigits();
122
+ } else {
123
+ time = state.lastTime;
124
+ digits = incremented.digits;
125
+ }
126
+ }
127
+
128
+ const timeEncoded = yield* encodeTime(time);
129
+ const id = `${timeEncoded}${encodeRandomDigits(digits)}`;
130
+ const decoded = yield* Schema.decodeUnknown(EventId)(id);
131
+ return [
132
+ decoded,
133
+ { lastTime: time, lastRandom: digits }
134
+ ] as const;
135
+ })
136
+ )
137
+ );
138
+
139
+ const appendWithClient = Effect.fn("StoreWriter.appendWithClient")(
140
+ (client: SqlClient.SqlClient, event: PostEvent) =>
141
+ Effect.gen(function* () {
142
+ const id = yield* generateEventId();
143
+ const record = PostEventRecord.make({
144
+ id,
145
+ version: 1,
146
+ event
147
+ });
148
+ const payloadJson = yield* Schema.encode(
149
+ Schema.parseJson(PostEventRecord)
150
+ )(record);
151
+ const postUri =
152
+ record.event._tag === "PostUpsert"
153
+ ? record.event.post.uri
154
+ : record.event.uri;
155
+ const createdAt =
156
+ record.event.meta.createdAt instanceof Date
157
+ ? record.event.meta.createdAt.toISOString()
158
+ : new Date(record.event.meta.createdAt).toISOString();
159
+
160
+ const insertEvent = SqlSchema.findOne({
161
+ Request: eventLogInsertRow,
162
+ Result: eventLogSeqRow,
163
+ execute: (row) =>
164
+ client`INSERT INTO event_log ${client.insert(row)}
165
+ RETURNING event_seq`
166
+ });
167
+
168
+ const inserted = yield* insertEvent({
169
+ event_id: record.id,
170
+ event_type: record.event._tag,
171
+ post_uri: postUri,
172
+ payload_json: payloadJson,
173
+ created_at: createdAt,
174
+ source: record.event.meta.source
175
+ }).pipe(
176
+ Effect.flatMap(
177
+ Option.match({
178
+ onNone: () =>
179
+ Effect.dieMessage("Expected event_seq from event_log insert."),
180
+ onSome: (row) => Effect.succeed(row)
181
+ })
182
+ )
183
+ );
184
+ return { seq: inserted.event_seq, record } satisfies EventLogEntry;
185
+ })
186
+ );
187
+
188
+ const append = Effect.fn("StoreWriter.append")(
189
+ (store: StoreRef, event: PostEvent) =>
190
+ storeDb
191
+ .withClient(store, (client) =>
192
+ appendWithClient(client, event).pipe(client.withTransaction)
193
+ )
194
+ .pipe(Effect.mapError(toStoreIoError(store.root)))
195
+ );
196
+
197
+ return StoreWriter.of({ append, appendWithClient });
198
+ })
199
+ );
200
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Sync Checkpoint Store Service
3
+ *
4
+ * Manages checkpoints for sync operations to enable resumable data synchronization.
5
+ * Stores the last sync position (cursor/timestamp) for each data source, allowing
6
+ * sync operations to resume from where they left off rather than starting over.
7
+ *
8
+ * Checkpoints are stored per-store and per-data-source combination, using a
9
+ * prefixed key-value store structure for isolation.
10
+ *
11
+ * @module services/sync-checkpoint-store
12
+ */
13
+
14
+ import { Context, Effect, Layer, Option, Schema } from "effect";
15
+ import { StoreIoError } from "../domain/errors.js";
16
+ import { DataSourceSchema, SyncCheckpoint, type DataSource, dataSourceKey } from "../domain/sync.js";
17
+ import { EventSeq, Timestamp, type StorePath } from "../domain/primitives.js";
18
+ import type { StoreRef } from "../domain/store.js";
19
+ import { StoreDb } from "./store-db.js";
20
+
21
+ /**
22
+ * Converts an error to a StoreIoError with the given path.
23
+ * @param path - The store path for error context
24
+ * @returns A function that creates StoreIoError from any cause
25
+ */
26
+ const toStoreIoError = (path: StorePath) => (cause: unknown) =>
27
+ StoreIoError.make({ path, cause });
28
+
29
+ const checkpointRow = Schema.Struct({
30
+ source_key: Schema.String,
31
+ source_json: Schema.String,
32
+ cursor: Schema.NullOr(Schema.String),
33
+ last_event_seq: Schema.NullOr(EventSeq),
34
+ filter_hash: Schema.NullOr(Schema.String),
35
+ updated_at: Schema.String
36
+ });
37
+
38
+ const decodeSource = (raw: string) =>
39
+ Schema.decodeUnknown(Schema.parseJson(DataSourceSchema))(raw);
40
+
41
+ const encodeSource = (source: DataSource) =>
42
+ Schema.encode(Schema.parseJson(DataSourceSchema))(source);
43
+
44
+ /**
45
+ * Service for managing sync operation checkpoints.
46
+ *
47
+ * This service provides methods to load and save sync checkpoints, which track
48
+ * the last successfully processed position for each data source. This enables
49
+ * resumable sync operations that can pick up where they left off.
50
+ */
51
+ export class SyncCheckpointStore extends Context.Tag("@skygent/SyncCheckpointStore")<
52
+ SyncCheckpointStore,
53
+ {
54
+ /**
55
+ * Loads the sync checkpoint for a given store and data source.
56
+ *
57
+ * @param store - The store reference to load from
58
+ * @param source - The data source to get checkpoint for
59
+ * @returns Effect resolving to Option of SyncCheckpoint, or StoreIoError on failure
60
+ */
61
+ readonly load: (
62
+ store: StoreRef,
63
+ source: DataSource
64
+ ) => Effect.Effect<Option.Option<SyncCheckpoint>, StoreIoError>;
65
+
66
+ /**
67
+ * Saves a sync checkpoint for resumable operations.
68
+ *
69
+ * @param store - The store reference to save to
70
+ * @param checkpoint - The checkpoint data to persist
71
+ * @returns Effect resolving to void, or StoreIoError on failure
72
+ */
73
+ readonly save: (
74
+ store: StoreRef,
75
+ checkpoint: SyncCheckpoint
76
+ ) => Effect.Effect<void, StoreIoError>;
77
+ }
78
+ >() {
79
+ static readonly layer = Layer.effect(
80
+ SyncCheckpointStore,
81
+ Effect.gen(function* () {
82
+ const storeDb = yield* StoreDb;
83
+
84
+ const load = Effect.fn("SyncCheckpointStore.load")(
85
+ (store: StoreRef, source: DataSource) => {
86
+ const key = dataSourceKey(source);
87
+ return storeDb
88
+ .withClient(store, (client) =>
89
+ Effect.gen(function* () {
90
+ const rows = yield* client`SELECT
91
+ source_key,
92
+ source_json,
93
+ cursor,
94
+ last_event_seq,
95
+ filter_hash,
96
+ updated_at
97
+ FROM sync_checkpoints
98
+ WHERE source_key = ${key}`;
99
+ if (rows.length === 0) {
100
+ return Option.none<SyncCheckpoint>();
101
+ }
102
+ const decodedRows = yield* Schema.decodeUnknown(
103
+ Schema.Array(checkpointRow)
104
+ )(rows);
105
+ const row = decodedRows[0]!;
106
+ const decodedSource = yield* decodeSource(row.source_json);
107
+ const updatedAt = yield* Schema.decodeUnknown(Timestamp)(row.updated_at);
108
+ const checkpoint = SyncCheckpoint.make({
109
+ source: decodedSource,
110
+ cursor: row.cursor ?? undefined,
111
+ lastEventSeq: row.last_event_seq ?? undefined,
112
+ filterHash: row.filter_hash ?? undefined,
113
+ updatedAt
114
+ });
115
+ return Option.some(checkpoint);
116
+ })
117
+ )
118
+ .pipe(Effect.mapError(toStoreIoError(store.root)));
119
+ }
120
+ );
121
+
122
+ const save = Effect.fn("SyncCheckpointStore.save")(
123
+ (store: StoreRef, checkpoint: SyncCheckpoint) => {
124
+ const key = dataSourceKey(checkpoint.source);
125
+ return storeDb
126
+ .withClient(store, (client) =>
127
+ Effect.gen(function* () {
128
+ const sourceJson = yield* encodeSource(checkpoint.source);
129
+ const updatedAt = checkpoint.updatedAt.toISOString();
130
+ const lastSeq = checkpoint.lastEventSeq ?? null;
131
+ const cursor = checkpoint.cursor ?? null;
132
+ const filterHash = checkpoint.filterHash ?? null;
133
+ yield* client`INSERT INTO sync_checkpoints (
134
+ source_key,
135
+ source_json,
136
+ cursor,
137
+ last_event_seq,
138
+ filter_hash,
139
+ updated_at
140
+ )
141
+ VALUES (
142
+ ${key},
143
+ ${sourceJson},
144
+ ${cursor},
145
+ ${lastSeq},
146
+ ${filterHash},
147
+ ${updatedAt}
148
+ )
149
+ ON CONFLICT(source_key) DO UPDATE SET
150
+ source_json = excluded.source_json,
151
+ cursor = excluded.cursor,
152
+ filter_hash = excluded.filter_hash,
153
+ updated_at = excluded.updated_at,
154
+ last_event_seq = CASE
155
+ WHEN excluded.last_event_seq IS NULL THEN sync_checkpoints.last_event_seq
156
+ WHEN sync_checkpoints.last_event_seq IS NULL THEN excluded.last_event_seq
157
+ WHEN excluded.last_event_seq >= sync_checkpoints.last_event_seq THEN excluded.last_event_seq
158
+ ELSE sync_checkpoints.last_event_seq
159
+ END`;
160
+ })
161
+ )
162
+ .pipe(Effect.mapError(toStoreIoError(store.root)));
163
+ }
164
+ );
165
+
166
+ return SyncCheckpointStore.of({ load, save });
167
+ })
168
+ );
169
+ }