diff --git a/apps/api/lexicons/notification/defs.json b/apps/api/lexicons/notification/defs.json index d0c8b81b..e356f08c 100644 --- a/apps/api/lexicons/notification/defs.json +++ b/apps/api/lexicons/notification/defs.json @@ -12,13 +12,13 @@ }, "did": { "type": "string", - "format": "did", - "description": "The decentralized identifier of the actor." + "description": "The decentralized identifier of the actor.", + "format": "did" }, "handle": { "type": "string", - "format": "at-identifier", - "description": "The handle of the actor." + "description": "The handle of the actor.", + "format": "at-identifier" }, "displayName": { "type": "string", @@ -26,14 +26,19 @@ }, "avatar": { "type": "string", - "format": "uri", - "description": "The URL of the actor's avatar image." + "description": "The URL of the actor's avatar image.", + "format": "uri" } } }, "notificationView": { "type": "object", - "required": ["id", "type", "read", "createdAt"], + "required": [ + "id", + "type", + "read", + "createdAt" + ], "properties": { "id": { "type": "string", @@ -57,8 +62,8 @@ }, "createdAt": { "type": "string", - "format": "datetime", - "description": "When the notification was created." + "description": "When the notification was created.", + "format": "datetime" }, "subjectUri": { "type": "string", diff --git a/apps/api/lexicons/notification/getUnreadCount.json b/apps/api/lexicons/notification/getUnreadCount.json index 4c484383..f777e630 100644 --- a/apps/api/lexicons/notification/getUnreadCount.json +++ b/apps/api/lexicons/notification/getUnreadCount.json @@ -9,7 +9,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["count"], + "required": [ + "count" + ], "properties": { "count": { "type": "integer", diff --git a/apps/api/lexicons/notification/listNotifications.json b/apps/api/lexicons/notification/listNotifications.json index 682c44a0..bcd7f14e 100644 --- a/apps/api/lexicons/notification/listNotifications.json +++ b/apps/api/lexicons/notification/listNotifications.json @@ -23,7 +23,10 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["notifications", "unreadCount"], + "required": [ + "notifications", + "unreadCount" + ], "properties": { "notifications": { "type": "array", diff --git a/apps/api/lexicons/notification/updateSeen.json b/apps/api/lexicons/notification/updateSeen.json index 2a12ffc7..4c4f3f3e 100644 --- a/apps/api/lexicons/notification/updateSeen.json +++ b/apps/api/lexicons/notification/updateSeen.json @@ -24,7 +24,9 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["unreadCount"], + "required": [ + "unreadCount" + ], "properties": { "unreadCount": { "type": "integer", diff --git a/apps/api/package.json b/apps/api/package.json index 14789b08..e103ceb0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "type": "module", "module": "dist/index.js", "scripts": { - "lexgen": "lex gen-server ./src/lexicon ./src/tealfm/lexicons/teal/**/* ./lexicons/**/* ./lexicons/* ./src/tealfm/lexicons/**/*", + "lexgen": "tsx ./scripts/lexgen.ts", "dev": "concurrently 'tsx --watch ./src/index.ts' 'tsx --watch ./src/server.ts'", "tsx:index": "tsx src/index.ts", "prod": "node --enable-source-maps dist/index.js", diff --git a/apps/api/pkl/defs/notification/defs.pkl b/apps/api/pkl/defs/notification/defs.pkl new file mode 100644 index 00000000..dbe1c3df --- /dev/null +++ b/apps/api/pkl/defs/notification/defs.pkl @@ -0,0 +1,82 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.notification.defs" +defs = new Mapping { + ["notificationActor"] = new ObjectType { + type = "object" + description = "The user who triggered a notification." + properties { + ["id"] = new StringType { + type = "string" + description = "The unique identifier of the actor." + } + ["did"] = new StringType { + type = "string" + format = "did" + description = "The decentralized identifier of the actor." + } + ["handle"] = new StringType { + type = "string" + format = "at-identifier" + description = "The handle of the actor." + } + ["displayName"] = new StringType { + type = "string" + description = "The display name of the actor." + } + ["avatar"] = new StringType { + type = "string" + format = "uri" + description = "The URL of the actor's avatar image." + } + } + } + ["notificationView"] = new ObjectType { + type = "object" + required = List("id", "type", "read", "createdAt") + properties { + ["id"] = new StringType { + type = "string" + description = "The unique identifier of the notification." + } + ["type"] = new StringType { + type = "string" + description = "The notification type: like_scrobble, follow, comment_scrobble, comment_profile, reply, or react_comment." + knownValues = List( + "like_scrobble", + "follow", + "comment_scrobble", + "comment_profile", + "reply", + "react_comment" + ) + } + ["read"] = new BooleanType { + type = "boolean" + description = "Whether the notification has been viewed." + } + ["createdAt"] = new StringType { + type = "string" + format = "datetime" + description = "When the notification was created." + } + ["subjectUri"] = new StringType { + type = "string" + description = "The at-uri of the subject the notification relates to." + } + ["shoutId"] = new StringType { + type = "string" + description = "The id of the related shout, if any." + } + ["shoutContent"] = new StringType { + type = "string" + description = "The content of the related shout, if any." + } + ["actor"] = new Ref { + type = "ref" + ref = "app.rocksky.notification.defs#notificationActor" + } + } + } +} diff --git a/apps/api/pkl/defs/notification/getUnreadCount.pkl b/apps/api/pkl/defs/notification/getUnreadCount.pkl new file mode 100644 index 00000000..f9f50504 --- /dev/null +++ b/apps/api/pkl/defs/notification/getUnreadCount.pkl @@ -0,0 +1,23 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.notification.getUnreadCount" +defs = new Mapping { + ["main"] { + type = "query" + description = "Get the number of unread notifications for the authenticated user." + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + required = List("count") + properties { + ["count"] = new IntegerType { + type = "integer" + description = "The number of unread notifications." + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/notification/listNotifications.pkl b/apps/api/pkl/defs/notification/listNotifications.pkl new file mode 100644 index 00000000..85190cd0 --- /dev/null +++ b/apps/api/pkl/defs/notification/listNotifications.pkl @@ -0,0 +1,47 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.notification.listNotifications" +defs = new Mapping { + ["main"] { + type = "query" + description = "List notifications for the authenticated user, most recent first." + parameters = new Params { + properties { + ["limit"] = new IntegerType { + type = "integer" + maximum = 100 + minimum = 1 + default = 30 + } + ["cursor"] = new StringType { + type = "string" + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + required = List("notifications", "unreadCount") + properties { + ["notifications"] = new Array { + type = "array" + items = new Ref { + type = "ref" + ref = "app.rocksky.notification.defs#notificationView" + } + } + ["unreadCount"] = new IntegerType { + type = "integer" + description = "The number of unread notifications." + } + ["cursor"] = new StringType { + type = "string" + description = "A cursor value to pass to subsequent calls to get the next page of results." + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/notification/updateSeen.pkl b/apps/api/pkl/defs/notification/updateSeen.pkl new file mode 100644 index 00000000..7e2fae0d --- /dev/null +++ b/apps/api/pkl/defs/notification/updateSeen.pkl @@ -0,0 +1,38 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.notification.updateSeen" +defs = new Mapping { + ["main"] { + type = "procedure" + description = "Mark notifications as viewed. When no ids are provided, marks all of the authenticated user's notifications as viewed." + input { + encoding = "application/json" + schema { + type = "object" + properties { + ["ids"] = new Array { + type = "array" + items = new StringType { + type = "string" + } + description = "The ids of the notifications to mark as viewed. Omit to mark all." + } + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + required = List("unreadCount") + properties { + ["unreadCount"] = new IntegerType { + type = "integer" + description = "The number of unread notifications remaining." + } + } + } + } + } +} diff --git a/apps/api/pkl/schema/lexicon.pkl b/apps/api/pkl/schema/lexicon.pkl index 17006236..e2366289 100644 --- a/apps/api/pkl/schema/lexicon.pkl +++ b/apps/api/pkl/schema/lexicon.pkl @@ -9,6 +9,7 @@ class StringType extends BaseType { minLength: Int? maxLength: Int? format: "uri" | "datetime" | "cid" | "at-uri" | "at-identifier" | "did" | Null = null + knownValues: List? = null } class IntegerType extends BaseType { @@ -67,7 +68,7 @@ class Output { class Query extends BaseType { type: "query" - parameters: Params + parameters: Params? output: Output } diff --git a/apps/api/scripts/lexgen.ts b/apps/api/scripts/lexgen.ts new file mode 100644 index 00000000..9e653da8 --- /dev/null +++ b/apps/api/scripts/lexgen.ts @@ -0,0 +1,60 @@ +import { consola } from "consola"; +import { existsSync, readdirSync, realpathSync, statSync } from "fs"; +import { join } from "path"; +import { $ } from "zx"; + +/** + * Recursively collect every `.json` lexicon under `dir`. + */ +function collectLexicons(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...collectLexicons(full)); + } else if (entry.endsWith(".json")) { + out.push(full); + } + } + return out; +} + +const ROOTS = ["lexicons", "src/tealfm/lexicons"]; +const OUT_DIR = "./src/lexicon"; + +// De-duplicate by real path. The previous glob command +// (`./lexicons/**/* ./lexicons/* ./src/tealfm/lexicons/teal/**/* ./src/tealfm/lexicons/**/*`) +// matched several files twice — `**/*` already covers `*`, and the two tealfm +// globs overlap — which made lex-cli throw " has already been registered" +// and abort mid-run (leaving stale/deleted generated files). A unique list +// avoids that entirely. +const files = [ + ...new Set( + ROOTS.filter((root) => existsSync(root)) + .flatMap((root) => collectLexicons(root)) + .map((f) => realpathSync(f)), + ), +]; + +// Prefer a locally-installed lex binary; fall back to the pinned package via +// bunx so the script works even when node_modules isn't fully populated. The +// version must match the one in package.json (`@atproto/lex-cli`) — newer +// majors emit a completely different output shape. +const lex = existsSync("node_modules/.bin/lex") + ? ["node_modules/.bin/lex"] + : ["bunx", "@atproto/lex-cli@0.5.7"]; + +consola.info(`Generating server API from ${files.length} lexicons…`); +await $`${lex} gen-server ${OUT_DIR} --yes ${files}`; + +// lex-cli emits single-quote code with plain `import`/empty `interface`. +// Normalise to the repo's committed style: `biome format` handles quotes & +// semicolons, then `biome lint --write` applies the safe style fixes +// (useImportType → `import type`, noEmptyInterface → `type X = {}`). We use +// `lint` rather than `check` on purpose: `check` would also run the +// organizeImports assist and reorder every import block. +consola.info("Normalising generated code with Biome…"); +await $`npx biome format --write ${OUT_DIR}`; +await $`npx biome lint --write ${OUT_DIR}`.nothrow(); + +consola.success(`Generated ${files.length} lexicons into ${OUT_DIR}`);