Scratch space for learning atproto app development

Switch to a multiple-status model

Changed files
+32 -47
lexicons
src
firehose
lexicon
types
com
example
+2 -2
lexicons/status.json
···
"key": "literal:self",
"record": {
"type": "object",
-
"required": ["status", "updatedAt"],
"properties": {
"status": {
"type": "string",
···
"maxGraphemes": 1,
"maxLength": 32
},
-
"updatedAt": { "type": "string", "format": "datetime" }
}
}
}
···
"key": "literal:self",
"record": {
"type": "object",
+
"required": ["status", "createdAt"],
"properties": {
"status": {
"type": "string",
···
"maxGraphemes": 1,
"maxLength": 32
},
+
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
+2 -25
package-lock.json
···
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@atproto/identity": "^0.4.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
"kysely": "^0.27.4",
"multiformats": "^9.9.0",
"pino": "^9.3.2",
-
"pino-http": "^10.0.0",
"uhtml": "^4.5.9"
},
"devDependencies": {
···
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.1.tgz",
"integrity": "sha512-uL7kQIcBTbvkBDNfxMXL6lBH4fO2DQpHd2BryJxMtbw/4iEPKe9xBYApwECHhEIk9+zhhpTRZ15FJ3gxTXN82Q==",
"dependencies": {
"@atproto/common-web": "^0.3.0",
"@ipld/dag-cbor": "^7.0.3",
···
"resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz",
"integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A=="
},
-
"node_modules/get-caller-file": {
-
"version": "2.0.5",
-
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
-
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-
"engines": {
-
"node": "6.* || 8.* || >= 10.*"
-
}
-
},
"node_modules/get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
···
"readable-stream": "^4.0.0",
"split2": "^4.0.0"
}
-
},
-
"node_modules/pino-http": {
-
"version": "10.2.0",
-
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.2.0.tgz",
-
"integrity": "sha512-am03BxnV3Ckx68OkbH0iZs3indsrH78wncQ6w1w51KroIbvJZNImBKX2X1wjdY8lSyaJ0UrX/dnO2DY3cTeCRw==",
-
"dependencies": {
-
"get-caller-file": "^2.0.5",
-
"pino": "^9.0.0",
-
"pino-std-serializers": "^7.0.0",
-
"process-warning": "^3.0.0"
-
}
-
},
-
"node_modules/pino-http/node_modules/process-warning": {
-
"version": "3.0.0",
-
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
-
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="
},
"node_modules/pino-pretty": {
"version": "11.2.2",
···
"version": "0.0.1",
"license": "MIT",
"dependencies": {
+
"@atproto/common": "^0.4.1",
"@atproto/identity": "^0.4.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
"kysely": "^0.27.4",
"multiformats": "^9.9.0",
"pino": "^9.3.2",
"uhtml": "^4.5.9"
},
"devDependencies": {
···
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.1.tgz",
"integrity": "sha512-uL7kQIcBTbvkBDNfxMXL6lBH4fO2DQpHd2BryJxMtbw/4iEPKe9xBYApwECHhEIk9+zhhpTRZ15FJ3gxTXN82Q==",
+
"license": "MIT",
"dependencies": {
"@atproto/common-web": "^0.3.0",
"@ipld/dag-cbor": "^7.0.3",
···
"resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz",
"integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A=="
},
"node_modules/get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
···
"readable-stream": "^4.0.0",
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "11.2.2",
+1
package.json
···
"clean": "rimraf dist coverage"
},
"dependencies": {
"@atproto/identity": "^0.4.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
"clean": "rimraf dist coverage"
},
"dependencies": {
+
"@atproto/common": "^0.4.1",
"@atproto/identity": "^0.4.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
+5 -3
src/db.ts
···
}
export type Status = {
authorDid: string
status: string
-
updatedAt: string
indexedAt: string
}
···
async up(db: Kysely<unknown>) {
await db.schema
.createTable('status')
-
.addColumn('authorDid', 'varchar', (col) => col.primaryKey())
.addColumn('status', 'varchar', (col) => col.notNull())
-
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
.execute()
await db.schema
···
}
export type Status = {
+
uri: string
authorDid: string
status: string
+
createdAt: string
indexedAt: string
}
···
async up(db: Kysely<unknown>) {
await db.schema
.createTable('status')
+
.addColumn('uri', 'varchar', (col) => col.primaryKey())
+
.addColumn('authorDid', 'varchar', (col) => col.notNull())
.addColumn('status', 'varchar', (col) => col.notNull())
+
.addColumn('createdAt', 'varchar', (col) => col.notNull())
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
.execute()
await db.schema
+9 -3
src/firehose/ingester.ts
···
await this.db
.insertInto('status')
.values({
authorDid: evt.author,
status: record.status,
-
updatedAt: record.updatedAt,
indexedAt: new Date().toISOString(),
})
.onConflict((oc) =>
-
oc.column('authorDid').doUpdateSet({
status: record.status,
-
updatedAt: record.updatedAt,
indexedAt: new Date().toISOString(),
})
)
.execute()
}
}
}
}
···
await this.db
.insertInto('status')
.values({
+
uri: evt.uri.toString(),
authorDid: evt.author,
status: record.status,
+
createdAt: record.createdAt,
indexedAt: new Date().toISOString(),
})
.onConflict((oc) =>
+
oc.column('uri').doUpdateSet({
status: record.status,
indexedAt: new Date().toISOString(),
})
)
.execute()
}
+
} else if (
+
evt.event === 'delete' &&
+
evt.collection === 'com.example.status'
+
) {
+
// Remove the status from our SQLite
+
await this.db.deleteFrom('status').where({ uri: evt.uri.toString() })
}
}
}
+2 -2
src/lexicon/lexicons.ts
···
key: 'literal:self',
record: {
type: 'object',
-
required: ['status', 'updatedAt'],
properties: {
status: {
type: 'string',
···
maxGraphemes: 1,
maxLength: 32,
},
-
updatedAt: {
type: 'string',
format: 'datetime',
},
···
key: 'literal:self',
record: {
type: 'object',
+
required: ['status', 'createdAt'],
properties: {
status: {
type: 'string',
···
maxGraphemes: 1,
maxLength: 32,
},
+
createdAt: {
type: 'string',
format: 'datetime',
},
+1 -1
src/lexicon/types/com/example/status.ts
···
export interface Record {
status: string
-
updatedAt: string
[k: string]: unknown
}
···
export interface Record {
status: string
+
createdAt: string
[k: string]: unknown
}
+10 -11
src/routes.ts
···
import type { IncomingMessage, ServerResponse } from 'node:http'
import { OAuthResolverError } from '@atproto/oauth-client-node'
import { isValidHandle } from '@atproto/syntax'
import express from 'express'
import { getIronSession } from 'iron-session'
import type { AppContext } from '#/index'
···
.selectFrom('status')
.selectAll()
.where('authorDid', '=', agent.accountDid)
.executeTakeFirst()
: undefined
···
}
// Construct & validate their status record
const record = {
$type: 'com.example.status',
status: req.body?.status,
-
updatedAt: new Date().toISOString(),
}
if (!Status.validateRecord(record).success) {
return res.status(400).json({ error: 'Invalid status' })
}
try {
// Write the status record to the user's repository
-
await agent.com.atproto.repo.putRecord({
repo: agent.accountDid,
collection: 'com.example.status',
-
rkey: 'self',
record,
validate: false,
})
} catch (err) {
ctx.logger.warn({ err }, 'failed to write record')
return res.status(500).json({ error: 'Failed to write record' })
···
await ctx.db
.insertInto('status')
.values({
authorDid: agent.accountDid,
status: record.status,
-
updatedAt: record.updatedAt,
indexedAt: new Date().toISOString(),
})
-
.onConflict((oc) =>
-
oc.column('authorDid').doUpdateSet({
-
status: record.status,
-
updatedAt: record.updatedAt,
-
indexedAt: new Date().toISOString(),
-
})
-
)
.execute()
} catch (err) {
ctx.logger.warn(
···
import type { IncomingMessage, ServerResponse } from 'node:http'
import { OAuthResolverError } from '@atproto/oauth-client-node'
import { isValidHandle } from '@atproto/syntax'
+
import { TID } from '@atproto/common'
import express from 'express'
import { getIronSession } from 'iron-session'
import type { AppContext } from '#/index'
···
.selectFrom('status')
.selectAll()
.where('authorDid', '=', agent.accountDid)
+
.orderBy('indexedAt', 'desc')
.executeTakeFirst()
: undefined
···
}
// Construct & validate their status record
+
const rkey = TID.nextStr()
const record = {
$type: 'com.example.status',
status: req.body?.status,
+
createdAt: new Date().toISOString(),
}
if (!Status.validateRecord(record).success) {
return res.status(400).json({ error: 'Invalid status' })
}
+
let uri
try {
// Write the status record to the user's repository
+
const res = await agent.com.atproto.repo.putRecord({
repo: agent.accountDid,
collection: 'com.example.status',
+
rkey,
record,
validate: false,
})
+
uri = res.data.uri
} catch (err) {
ctx.logger.warn({ err }, 'failed to write record')
return res.status(500).json({ error: 'Failed to write record' })
···
await ctx.db
.insertInto('status')
.values({
+
uri,
authorDid: agent.accountDid,
status: record.status,
+
createdAt: record.createdAt,
indexedAt: new Date().toISOString(),
})
.execute()
} catch (err) {
ctx.logger.warn(