lex-gql 0.1.0 → 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.
package/README.md CHANGED
@@ -133,6 +133,100 @@ import {
133
133
  } from 'lex-gql'
134
134
  ```
135
135
 
136
+ ## Query Port Interface
137
+
138
+ lex-gql follows the hexagonal architecture pattern. Your data layer implements the **query port** interface:
139
+
140
+ ### Operation Types
141
+
142
+ ```typescript
143
+ type Operation =
144
+ | { type: 'findMany'; collection: string; where: WhereClause[]; pagination: Pagination; sort?: SortClause[] }
145
+ | { type: 'aggregate'; collection: string; where: WhereClause[]; groupBy?: string[] }
146
+ | { type: 'create'; collection: string; rkey?: string; record: object }
147
+ | { type: 'update'; collection: string; rkey: string; record: object }
148
+ | { type: 'delete'; collection: string; rkey: string }
149
+
150
+ // Field condition
151
+ type FieldCondition = { field: string; op: 'eq' | 'in' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte'; value: any }
152
+
153
+ // Logical operators (for AND/OR queries)
154
+ type LogicalCondition = { op: 'and' | 'or'; conditions: WhereClause[][] }
155
+
156
+ type WhereClause = FieldCondition | LogicalCondition
157
+ type SortClause = { field: string; dir: 'asc' | 'desc' }
158
+ type Pagination = { first?: number; after?: string; last?: number; before?: string }
159
+ ```
160
+
161
+ ### Cross-Collection URI Resolution
162
+
163
+ For batched forward join resolution, lex-gql issues `findMany` operations with `collection: '*'`. This special value means "query across all collections by URI":
164
+
165
+ ```typescript
166
+ // Forward join batch request
167
+ {
168
+ type: 'findMany',
169
+ collection: '*', // Special: resolve by URI, ignore collection filter
170
+ where: [{ field: 'uri', op: 'in', value: ['at://did1/...', 'at://did2/...'] }],
171
+ pagination: {}
172
+ }
173
+ ```
174
+
175
+ Adapters **must** handle this case by omitting the collection filter and returning records matching the URIs. The returned records must include a `collection` field for union type resolution.
176
+
177
+ ### Response Format
178
+
179
+ ```typescript
180
+ // For findMany
181
+ { rows: Record[]; hasNext: boolean; hasPrev: boolean }
182
+
183
+ // For aggregate
184
+ { count: number; groups: { [field]: value; count: number }[] }
185
+
186
+ // For mutations
187
+ Record | { uri: string }
188
+ ```
189
+
190
+ ### Standard Records Schema
191
+
192
+ For SQL-based adapters, we recommend this schema:
193
+
194
+ ```sql
195
+ CREATE TABLE records (
196
+ uri TEXT PRIMARY KEY,
197
+ did TEXT NOT NULL,
198
+ collection TEXT NOT NULL,
199
+ rkey TEXT NOT NULL,
200
+ cid TEXT,
201
+ record TEXT NOT NULL, -- JSON blob
202
+ indexed_at TEXT NOT NULL
203
+ );
204
+
205
+ CREATE INDEX idx_records_collection ON records(collection);
206
+ CREATE INDEX idx_records_did ON records(did);
207
+
208
+ CREATE TABLE actors (
209
+ did TEXT PRIMARY KEY,
210
+ handle TEXT NOT NULL
211
+ );
212
+ ```
213
+
214
+ ### Hydration Helpers
215
+
216
+ Use these helpers to transform database rows into lex-gql format:
217
+
218
+ ```javascript
219
+ import { hydrateBlobs, hydrateRecord } from 'lex-gql';
220
+
221
+ // hydrateBlobs - inject DID into blob fields for URL resolution
222
+ const record = JSON.parse(row.record);
223
+ const hydrated = hydrateBlobs(record, row.did);
224
+
225
+ // hydrateRecord - full transformation from standard schema
226
+ const rows = db.query('SELECT r.*, a.handle FROM records r LEFT JOIN actors a ON r.did = a.did');
227
+ const records = rows.map(hydrateRecord);
228
+ ```
229
+
136
230
  ## Generated Schema Structure
137
231
 
138
232
  For each record lexicon, lex-gql generates:
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "lex-gql",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Generate a complete GraphQL API from AT Protocol lexicons",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "main": "lex-gql.js",
8
- "types": "lex-gql.d.ts",
7
+ "main": "src/lex-gql.js",
8
+ "types": "src/lex-gql.d.ts",
9
9
  "files": [
10
- "lex-gql.js",
11
- "lex-gql.d.ts"
10
+ "src/lex-gql.js",
11
+ "src/lex-gql.d.ts"
12
12
  ],
13
13
  "keywords": [
14
14
  "graphql",
@@ -35,8 +35,9 @@
35
35
  "vitest": "^1.0.0"
36
36
  },
37
37
  "scripts": {
38
+ "build": "tsc",
38
39
  "test": "vitest run",
39
40
  "test:watch": "vitest",
40
- "typecheck": "tsc"
41
+ "typecheck": "tsc --noEmit"
41
42
  }
42
43
  }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Convert NSID to PascalCase type name
3
+ * @param {string} nsid - e.g. "app.bsky.feed.post"
4
+ * @returns {string} - e.g. "AppBskyFeedPost"
5
+ */
6
+ export function nsidToTypeName(nsid: string): string;
7
+ /**
8
+ * Convert NSID to camelCase field name
9
+ * @param {string} nsid - e.g. "app.bsky.feed.post"
10
+ * @returns {string} - e.g. "appBskyFeedPost"
11
+ */
12
+ export function nsidToFieldName(nsid: string): string;
13
+ /**
14
+ * Extract collection name (last segment) from NSID
15
+ * @param {string} nsid - e.g. "app.bsky.feed.post"
16
+ * @returns {string} - e.g. "post"
17
+ */
18
+ export function nsidToCollectionName(nsid: string): string;
19
+ /**
20
+ * Map AT Protocol lexicon type to GraphQL type name
21
+ * @param {string} lexiconType
22
+ * @returns {string}
23
+ */
24
+ export function mapLexiconType(lexiconType: string): string;
25
+ /**
26
+ * Parse a ref URI into nsid and fragment
27
+ * @param {string} refUri - e.g. "xyz.statusphere.post#embed" or "#mention"
28
+ * @returns {{ nsid: string|null, fragment: string }}
29
+ */
30
+ export function parseRefUri(refUri: string): {
31
+ nsid: string | null;
32
+ fragment: string;
33
+ };
34
+ /**
35
+ * Resolve a ref string to a full registry key
36
+ * @param {string} ref - The ref string (e.g., "#replyRef" or "app.bsky.embed.images")
37
+ * @param {string} parentLexiconId - The lexicon ID containing this ref
38
+ * @returns {string} Full registry key
39
+ */
40
+ export function resolveRefKey(ref: string, parentLexiconId: string): string;
41
+ /**
42
+ * Convert a ref URI to a GraphQL type name
43
+ * @param {string} refUri - e.g. "fm.teal.alpha.feed.defs#artist"
44
+ * @returns {string} - e.g. "FmTealAlphaFeedDefsArtist"
45
+ */
46
+ export function refToTypeName(refUri: string): string;
47
+ /**
48
+ * Inject DID into blob objects for URL resolution.
49
+ * Blobs need the parent record's DID to generate CDN URLs.
50
+ *
51
+ * @param {any} obj - Record object or value to hydrate
52
+ * @param {string} did - DID to inject into blob objects
53
+ * @returns {any} - Hydrated object with did added to blobs
54
+ *
55
+ * @example
56
+ * const record = JSON.parse(row.record);
57
+ * const hydrated = hydrateBlobs(record, row.did);
58
+ */
59
+ export function hydrateBlobs(obj: any, did: string): any;
60
+ /**
61
+ * @typedef {Object} DatabaseRow
62
+ * @property {string} uri - Record AT URI
63
+ * @property {string} did - Author DID
64
+ * @property {string} collection - Lexicon NSID
65
+ * @property {string} [cid] - Content ID
66
+ * @property {string|Object} record - JSON string or parsed object
67
+ * @property {string} indexed_at - ISO timestamp
68
+ * @property {string} [handle] - Actor handle from actors table join
69
+ */
70
+ /**
71
+ * Transform a database row into lex-gql record format.
72
+ * Expects the standard records table schema.
73
+ *
74
+ * Standard schema:
75
+ * - uri: TEXT (record AT URI)
76
+ * - did: TEXT (author DID)
77
+ * - collection: TEXT (lexicon NSID)
78
+ * - cid: TEXT (optional, content ID)
79
+ * - record: TEXT (JSON) or Object
80
+ * - indexed_at: TEXT (ISO timestamp)
81
+ * - handle: TEXT (optional, actor handle from actors table join)
82
+ *
83
+ * @param {DatabaseRow} row - Database row
84
+ * @returns {Record<string, any>} - Hydrated record for lex-gql
85
+ *
86
+ * @example
87
+ * const rows = db.query('SELECT r.*, a.handle FROM records r LEFT JOIN actors a ON r.did = a.did');
88
+ * const records = rows.map(hydrateRecord);
89
+ */
90
+ export function hydrateRecord(row: DatabaseRow): Record<string, any>;
91
+ /**
92
+ * Parse a lexicon JSON object into structured form
93
+ * @param {RawLexiconJson} json - Raw lexicon JSON
94
+ * @returns {Lexicon}
95
+ */
96
+ export function parseLexicon(json: RawLexiconJson): Lexicon;
97
+ /**
98
+ * Build a GraphQL schema from parsed lexicons
99
+ * @param {Lexicon[]} lexicons
100
+ * @returns {GraphQLSchema}
101
+ */
102
+ export function buildSchema(lexicons: Lexicon[]): GraphQLSchema;
103
+ /**
104
+ * Create a GraphQL adapter with query resolvers
105
+ * @param {Lexicon[]} lexicons
106
+ * @param {AdapterOptions} options
107
+ * @returns {{
108
+ * schema: GraphQLSchema,
109
+ * execute: (query: string, variables?: Record<string, unknown>) => Promise<any>,
110
+ * subscribe: (query: string, variables?: Record<string, unknown>) => Promise<AsyncIterable<import('graphql').ExecutionResult>>
111
+ * }}
112
+ */
113
+ export function createAdapter(lexicons: Lexicon[], options: AdapterOptions): {
114
+ schema: GraphQLSchema;
115
+ execute: (query: string, variables?: Record<string, unknown>) => Promise<any>;
116
+ subscribe: (query: string, variables?: Record<string, unknown>) => Promise<AsyncIterable<import("graphql").ExecutionResult>>;
117
+ };
118
+ export namespace ErrorCodes {
119
+ let INVALID_LEXICON: string;
120
+ let UNSUPPORTED_VERSION: string;
121
+ let QUERY_FAILED: string;
122
+ let VALIDATION_FAILED: string;
123
+ }
124
+ /**
125
+ * Custom error class for lex-gql errors
126
+ */
127
+ export class LexGqlError extends Error {
128
+ /**
129
+ * @param {string} message
130
+ * @param {string} code
131
+ * @param {Object} [details]
132
+ */
133
+ constructor(message: string, code: string, details?: Object);
134
+ code: string;
135
+ details: Object;
136
+ }
137
+ export type DatabaseRow = {
138
+ /**
139
+ * - Record AT URI
140
+ */
141
+ uri: string;
142
+ /**
143
+ * - Author DID
144
+ */
145
+ did: string;
146
+ /**
147
+ * - Lexicon NSID
148
+ */
149
+ collection: string;
150
+ /**
151
+ * - Content ID
152
+ */
153
+ cid?: string | undefined;
154
+ /**
155
+ * - JSON string or parsed object
156
+ */
157
+ record: string | Object;
158
+ /**
159
+ * - ISO timestamp
160
+ */
161
+ indexed_at: string;
162
+ /**
163
+ * - Actor handle from actors table join
164
+ */
165
+ handle?: string | undefined;
166
+ };
167
+ /**
168
+ * A where clause for filtering records.
169
+ * Can be a field condition or a logical operator (AND/OR).
170
+ *
171
+ * Field condition: { field: 'text', op: 'eq', value: 'hello' }
172
+ * Logical AND: { op: 'and', conditions: WhereClause[][] }
173
+ * Logical OR: { op: 'or', conditions: WhereClause[][] }
174
+ */
175
+ export type WhereClause = {
176
+ /**
177
+ * - Field name (for field conditions)
178
+ */
179
+ field?: string | undefined;
180
+ /**
181
+ * - Operator: 'eq' | 'in' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte' | 'and' | 'or'
182
+ */
183
+ op: string;
184
+ /**
185
+ * - Value to compare (for field conditions)
186
+ */
187
+ value?: any;
188
+ /**
189
+ * - Nested conditions (for and/or)
190
+ */
191
+ conditions?: WhereClause[][] | undefined;
192
+ };
193
+ export type SortClause = {
194
+ field: string;
195
+ dir: string;
196
+ };
197
+ export type Pagination = {
198
+ first?: number | undefined;
199
+ after?: string | undefined;
200
+ last?: number | undefined;
201
+ before?: string | undefined;
202
+ };
203
+ export type Aggregate = {
204
+ field: string;
205
+ fn: string;
206
+ };
207
+ export type Operation = {
208
+ type: "findMany" | "findOne" | "count" | "aggregate" | "create" | "update" | "delete";
209
+ collection: string;
210
+ where?: WhereClause[] | undefined;
211
+ select?: string[] | undefined;
212
+ sort?: SortClause[] | undefined;
213
+ pagination?: Pagination | undefined;
214
+ data?: Record<string, any> | undefined;
215
+ uri?: string | undefined;
216
+ rkey?: string | undefined;
217
+ groupBy?: string[] | undefined;
218
+ aggregates?: Aggregate[] | undefined;
219
+ limit?: number | undefined;
220
+ orderBy?: "COUNT_ASC" | "COUNT_DESC" | undefined;
221
+ arrayFields?: string[] | undefined;
222
+ };
223
+ export type AdapterOptions = {
224
+ query: (op: Operation) => Promise<any>;
225
+ subscribe?: ((op: SubscribeOperation) => AsyncIterable<any>) | undefined;
226
+ context?: Record<string, any> | undefined;
227
+ maxDepth?: number | undefined;
228
+ };
229
+ export type SubscriptionEvent = "created" | "updated" | "deleted";
230
+ export type SubscribeOperation = {
231
+ /**
232
+ * - The collection NSID (e.g., 'app.bsky.feed.post')
233
+ */
234
+ collection: string;
235
+ /**
236
+ * - The event type
237
+ */
238
+ event: SubscriptionEvent;
239
+ };
240
+ export type Property = {
241
+ name: string;
242
+ type: string;
243
+ required: boolean;
244
+ format?: string | null | undefined;
245
+ ref?: string | null | undefined;
246
+ refs?: string[] | null | undefined;
247
+ items?: ArrayItems | null | undefined;
248
+ };
249
+ export type ArrayItems = {
250
+ type: string;
251
+ ref?: string | null | undefined;
252
+ refs?: string[] | null | undefined;
253
+ };
254
+ export type RecordDef = {
255
+ type: string;
256
+ key?: string | null | undefined;
257
+ properties: Property[];
258
+ };
259
+ export type Lexicon = {
260
+ id: string;
261
+ defs: {
262
+ main: RecordDef | null;
263
+ others: Record<string, RecordDef>;
264
+ };
265
+ };
266
+ export type RawLexiconJson = {
267
+ id: string;
268
+ lexicon?: number | undefined;
269
+ defs?: Record<string, any> | undefined;
270
+ };
271
+ import { GraphQLSchema } from 'graphql';