service status on atproto

feat!: include current state for service / check in lexicon, let state include uri to previous state for easier traversal

ptr.pet bcc1fe2f 7ad815b1

verified
+443 -338
+26 -22
lexicon/check.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.check", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "required": ["name", "forService"], 11 - "properties": { 12 - "name": { 13 - "type": "string" 14 - }, 15 - "description": { 16 - "type": "string" 17 - }, 18 - "forService": { 19 - "type": "string", 20 - "format": "at-uri" 21 - } 2 + "lexicon": 1, 3 + "id": "systems.gaze.barometer.check", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "record": { 9 + "type": "object", 10 + "required": ["name", "forService"], 11 + "properties": { 12 + "name": { 13 + "type": "string" 14 + }, 15 + "description": { 16 + "type": "string" 17 + }, 18 + "forService": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "currentState": { 23 + "type": "string", 24 + "format": "at-uri" 25 + } 26 + } 27 + } 22 28 } 23 - } 24 29 } 25 - } 26 30 }
+21 -21
lexicon/host.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.host", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "any", 8 - "record": { 9 - "type": "object", 10 - "required": ["name", "os"], 11 - "properties": { 12 - "name": { 13 - "type": "string" 14 - }, 15 - "description": { 16 - "type": "string" 17 - }, 18 - "os": { 19 - "type": "string" 20 - } 2 + "lexicon": 1, 3 + "id": "systems.gaze.barometer.host", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "record": { 9 + "type": "object", 10 + "required": ["name", "os"], 11 + "properties": { 12 + "name": { 13 + "type": "string" 14 + }, 15 + "description": { 16 + "type": "string" 17 + }, 18 + "os": { 19 + "type": "string" 20 + } 21 + } 22 + } 21 23 } 22 - } 23 24 } 24 - } 25 25 }
+30 -26
lexicon/service.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.service", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "required": ["name"], 11 - "properties": { 12 - "name": { 13 - "type": "string" 14 - }, 15 - "description": { 16 - "type": "string" 17 - }, 18 - "hostedBy": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "appUri": { 23 - "type": "string", 24 - "format": "uri" 25 - } 2 + "lexicon": 1, 3 + "id": "systems.gaze.barometer.service", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "record": { 9 + "type": "object", 10 + "required": ["name"], 11 + "properties": { 12 + "name": { 13 + "type": "string" 14 + }, 15 + "description": { 16 + "type": "string" 17 + }, 18 + "appUri": { 19 + "type": "string", 20 + "format": "uri" 21 + }, 22 + "hostedBy": { 23 + "type": "string", 24 + "format": "at-uri" 25 + }, 26 + "currentState": { 27 + "type": "string", 28 + "format": "at-uri" 29 + } 30 + } 31 + } 26 32 } 27 - } 28 33 } 29 - } 30 34 }
+38 -42
lexicon/state.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.state", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "required": ["from", "to", "changedAt", "forService"], 11 - "properties": { 12 - "from": { 13 - "type": "string", 14 - "enum": [ 15 - "systems.gaze.barometer.status.healthy", 16 - "systems.gaze.barometer.status.degraded", 17 - "systems.gaze.barometer.status.unknown" 18 - ] 19 - }, 20 - "to": { 21 - "type": "string", 22 - "enum": [ 23 - "systems.gaze.barometer.status.healthy", 24 - "systems.gaze.barometer.status.degraded" 25 - ] 26 - }, 27 - "reason": { 28 - "type": "string" 29 - }, 30 - "changedAt": { 31 - "type": "string", 32 - "format": "datetime" 33 - }, 34 - "forService": { 35 - "type": "string", 36 - "format": "at-uri" 37 - }, 38 - "generatedBy": { 39 - "type": "string", 40 - "format": "at-uri" 41 - } 2 + "lexicon": 1, 3 + "id": "systems.gaze.barometer.state", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["state", "changedAt", "forService"], 11 + "properties": { 12 + "state": { 13 + "type": "string", 14 + "enum": [ 15 + "systems.gaze.barometer.status.healthy", 16 + "systems.gaze.barometer.status.degraded" 17 + ] 18 + }, 19 + "reason": { 20 + "type": "string" 21 + }, 22 + "changedAt": { 23 + "type": "string", 24 + "format": "datetime" 25 + }, 26 + "forService": { 27 + "type": "string", 28 + "format": "at-uri" 29 + }, 30 + "generatedBy": { 31 + "type": "string", 32 + "format": "at-uri" 33 + }, 34 + "previous": { 35 + "type": "string", 36 + "format": "at-uri" 37 + } 38 + } 39 + } 42 40 } 43 - } 44 41 } 45 - } 46 42 }
+7 -7
lexicon/status/degraded.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.status.degraded", 4 - "defs": { 5 - "main": { 6 - "type": "token", 7 - "description": "represents that a service / check is not working as it should" 2 + "lexicon": 1, 3 + "id": "systems.gaze.barometer.status.degraded", 4 + "defs": { 5 + "main": { 6 + "type": "token", 7 + "description": "represents that a service / check is not working as it should" 8 + } 8 9 } 9 - } 10 10 }
+7 -7
lexicon/status/healthy.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.status.healthy", 4 - "defs": { 5 - "main": { 6 - "type": "token", 7 - "description": "represents that a service / check is working properly" 2 + "lexicon": 1, 3 + "id": "systems.gaze.barometer.status.healthy", 4 + "defs": { 5 + "main": { 6 + "type": "token", 7 + "description": "represents that a service / check is working properly" 8 + } 8 9 } 9 - } 10 10 }
-10
lexicon/status/unknown.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "systems.gaze.barometer.status.unknown", 4 - "defs": { 5 - "main": { 6 - "type": "token", 7 - "description": "represents that the state of the service / check is unknown (only used for first from field in a state record)" 8 - } 9 - } 10 - }
-1
lib/src/lexicons/index.ts
··· 4 4 export * as SystemsGazeBarometerState from "./types/systems/gaze/barometer/state.js"; 5 5 export * as SystemsGazeBarometerStatusDegraded from "./types/systems/gaze/barometer/status/degraded.js"; 6 6 export * as SystemsGazeBarometerStatusHealthy from "./types/systems/gaze/barometer/status/healthy.js"; 7 - export * as SystemsGazeBarometerStatusUnknown from "./types/systems/gaze/barometer/status/unknown.js";
+2 -1
lib/src/lexicons/types/systems/gaze/barometer/check.ts
··· 3 3 import type {} from "@atcute/lexicons/ambient"; 4 4 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 - /*#__PURE__*/ v.tidString(), 6 + /*#__PURE__*/ v.string(), 7 7 /*#__PURE__*/ v.object({ 8 8 $type: /*#__PURE__*/ v.literal("systems.gaze.barometer.check"), 9 + currentState: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 9 10 description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 10 11 forService: /*#__PURE__*/ v.resourceUriString(), 11 12 name: /*#__PURE__*/ v.string(),
+2 -1
lib/src/lexicons/types/systems/gaze/barometer/service.ts
··· 3 3 import type {} from "@atcute/lexicons/ambient"; 4 4 5 5 const _mainSchema = /*#__PURE__*/ v.record( 6 - /*#__PURE__*/ v.tidString(), 6 + /*#__PURE__*/ v.string(), 7 7 /*#__PURE__*/ v.object({ 8 8 $type: /*#__PURE__*/ v.literal("systems.gaze.barometer.service"), 9 9 appUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), 10 + currentState: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 10 11 description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 11 12 hostedBy: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 12 13 name: /*#__PURE__*/ v.string(),
+2 -6
lib/src/lexicons/types/systems/gaze/barometer/state.ts
··· 8 8 $type: /*#__PURE__*/ v.literal("systems.gaze.barometer.state"), 9 9 changedAt: /*#__PURE__*/ v.datetimeString(), 10 10 forService: /*#__PURE__*/ v.resourceUriString(), 11 - from: /*#__PURE__*/ v.literalEnum([ 12 - "systems.gaze.barometer.status.degraded", 13 - "systems.gaze.barometer.status.healthy", 14 - "systems.gaze.barometer.status.unknown", 15 - ]), 16 11 generatedBy: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 12 + previous: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), 17 13 reason: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 18 - to: /*#__PURE__*/ v.literalEnum([ 14 + state: /*#__PURE__*/ v.literalEnum([ 19 15 "systems.gaze.barometer.status.degraded", 20 16 "systems.gaze.barometer.status.healthy", 21 17 ]),
-14
lib/src/lexicons/types/systems/gaze/barometer/status/unknown.ts
··· 1 - import type {} from "@atcute/lexicons"; 2 - import * as v from "@atcute/lexicons/validations"; 3 - 4 - const _mainSchema = /*#__PURE__*/ v.literal( 5 - "systems.gaze.barometer.status.unknown", 6 - ); 7 - 8 - type main$schematype = typeof _mainSchema; 9 - 10 - export interface mainSchema extends main$schematype {} 11 - 12 - export const mainSchema = _mainSchema as mainSchema; 13 - 14 - export type Main = v.InferInput<typeof mainSchema>;
+3
proxy/bun.lock
··· 12 12 "@atcute/lexicons": "^1.1.0", 13 13 "@atcute/tid": "^1.0.2", 14 14 "barometer-lexicon": "file:../lib", 15 + "nanoid": "^5.1.5", 15 16 "parsimmon": "^1.18.1", 16 17 }, 17 18 "devDependencies": { ··· 96 97 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 97 98 98 99 "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 100 + 101 + "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], 99 102 100 103 "parsimmon": ["parsimmon@1.18.1", "", {}, "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw=="], 101 104
+1
proxy/package.json
··· 24 24 "@atcute/lexicons": "^1.1.0", 25 25 "@atcute/tid": "^1.0.2", 26 26 "barometer-lexicon": "file:../lib", 27 + "nanoid": "^5.1.5", 27 28 "parsimmon": "^1.18.1" 28 29 } 29 30 }
+1 -3
proxy/src/config.ts
··· 16 16 check: (value: unknown) => boolean = () => true, 17 17 ): Value => { 18 18 const value = env[`${prefix}${name}`]; 19 - if (check(value)) { 20 - return value as Value; 21 - } 19 + if (check(value)) return value as Value; 22 20 throw `config key ${name} is invalid`; 23 21 }; 24 22 return {
+79 -74
proxy/src/jetstream.ts
··· 1 1 import { JetstreamSubscription } from "@atcute/jetstream"; 2 - import { is, parse, parseCanonicalResourceUri } from "@atcute/lexicons"; 2 + import { 3 + is, 4 + parse, 5 + parseCanonicalResourceUri, 6 + type RecordKey, 7 + } from "@atcute/lexicons"; 3 8 import { 4 9 SystemsGazeBarometerService, 5 10 SystemsGazeBarometerCheck, 6 - SystemsGazeBarometerState, 7 11 } from "barometer-lexicon"; 8 12 import { config } from "./config"; 9 13 import store, { type Service } from "./store"; 10 - import { expect, getRecord, log } from "./utils"; 14 + import { expect, getRecord, getUri, log, type ServiceUri } from "./utils"; 11 15 12 16 const subscription = new JetstreamSubscription({ 13 17 url: "wss://jetstream2.us-east.bsky.network", 14 18 wantedCollections: [ 15 19 "systems.gaze.barometer.service", 16 20 "systems.gaze.barometer.check", 21 + "systems.gaze.barometer.state", 17 22 ], 18 23 wantedDids: [config.repoDid], 19 24 }); 20 25 26 + const handleService = async ( 27 + record: Record<string, unknown>, 28 + rkey: RecordKey, 29 + ) => { 30 + const collection = "systems.gaze.barometer.service"; 31 + const serviceRecord = parse(SystemsGazeBarometerService.mainSchema, record); 32 + // we dont care if its a dangling service 33 + if (!serviceRecord.hostedBy) return true; 34 + const hostAtUri = expect(parseCanonicalResourceUri(serviceRecord.hostedBy)); 35 + // not our host 36 + if (hostAtUri.rkey !== store.hostname) return true; 37 + const serviceUri = getUri(collection, rkey); 38 + const service: Service = store.services.get(serviceUri) ?? { 39 + record: serviceRecord, 40 + checks: new Set(), 41 + rkey, 42 + }; 43 + store.services.set(serviceUri, { 44 + ...service, 45 + record: serviceRecord, 46 + }); 47 + return false; 48 + }; 49 + 50 + const handleCheck = async ( 51 + record: Record<string, unknown>, 52 + rkey: RecordKey, 53 + ) => { 54 + const collection = "systems.gaze.barometer.check"; 55 + const checkRecord = parse(SystemsGazeBarometerCheck.mainSchema, record); 56 + const checkUri = getUri(collection, rkey); 57 + const serviceUri = checkRecord.forService as ServiceUri; 58 + const maybeService = await store.getOrFetch(serviceUri); 59 + if (!maybeService.ok) { 60 + log.error( 61 + `can't fetch service record (${serviceUri}) for check record (${checkUri})`, 62 + ); 63 + return true; 64 + } 65 + const service = maybeService.value; 66 + service.checks.add(rkey); 67 + store.checks.set(checkUri, { 68 + record: checkRecord, 69 + rkey, 70 + }); 71 + store.services.set(serviceUri, service); 72 + return false; 73 + }; 74 + 21 75 export const handleEvents = async () => { 22 76 for await (const event of subscription) { 23 - if (event.kind !== "commit") { 24 - continue; 25 - } 77 + if (event.kind !== "commit") continue; 26 78 const { operation, collection, rkey } = event.commit; 27 79 // log.info(`${operation} at://${event.did}/${collection}/${rkey}`); 28 80 if (operation === "create" || operation === "update") { 29 81 const record = event.commit.record; 30 82 switch (collection) { 31 83 case "systems.gaze.barometer.service": { 32 - const serviceRecord = parse( 33 - SystemsGazeBarometerService.mainSchema, 34 - record, 35 - ); 36 - // we dont care if its a dangling service 37 - if (!serviceRecord.hostedBy) { 38 - continue; 39 - } 40 - const hostAtUri = expect( 41 - parseCanonicalResourceUri(serviceRecord.hostedBy), 42 - ); 43 - // not our host 44 - if (hostAtUri.rkey !== store.hostname) { 45 - continue; 46 - } 47 - const service: Service = store.services.get(rkey) ?? { 48 - record: serviceRecord, 49 - checks: new Set(), 50 - }; 51 - store.services.set(rkey, { 52 - ...service, 53 - record: serviceRecord, 54 - }); 84 + if (await handleService(record, rkey)) continue; 55 85 break; 56 86 } 57 87 case "systems.gaze.barometer.check": { 58 - const checkRecord = parse( 59 - SystemsGazeBarometerCheck.mainSchema, 60 - record, 61 - ); 62 - const parsedServiceAtUri = expect( 63 - parseCanonicalResourceUri(checkRecord.forService), 64 - ); 65 - let service = store.services.get(parsedServiceAtUri.rkey); 66 - if (!service) { 67 - const serviceRecord = await getRecord( 68 - "systems.gaze.barometer.service", 69 - parsedServiceAtUri.rkey, 70 - ); 71 - if (!serviceRecord.ok) { 72 - // cant get service record 73 - log.error( 74 - `can't fetch service record (${checkRecord.forService}) for check record (at://${event.did}/${collection}/${rkey})`, 75 - ); 76 - continue; 77 - } 78 - service = { 79 - record: serviceRecord.value, 80 - checks: new Set(), 81 - }; 82 - } 83 - service.checks.add(rkey); 84 - store.checks.set(rkey, { record: checkRecord }); 85 - store.services.set(parsedServiceAtUri.rkey, service); 88 + if (await handleCheck(record, rkey)) continue; 86 89 break; 87 90 } 88 91 } 89 92 } else { 90 93 switch (collection) { 91 94 case "systems.gaze.barometer.service": { 92 - const service = store.services.get(rkey); 93 - if (!service) { 94 - continue; 95 + const serviceUri = getUri(collection, rkey); 96 + const service = store.services.get(serviceUri); 97 + if (!service) continue; 98 + for (const checkRkey of service.checks) { 99 + store.checks.delete( 100 + getUri("systems.gaze.barometer.check", checkRkey), 101 + ); 95 102 } 96 - for (const checkKey of service.checks) { 97 - store.checks.delete(checkKey); 98 - } 99 - store.services.delete(rkey); 103 + store.services.delete(serviceUri); 100 104 break; 101 105 } 102 106 case "systems.gaze.barometer.check": { 103 - const check = store.checks.get(rkey); 104 - if (!check) { 105 - continue; 106 - } 107 - const parsedServiceAtUri = expect( 108 - parseCanonicalResourceUri(check.record.forService), 109 - ); 110 - const service = store.services.get(parsedServiceAtUri.rkey); 107 + const checkUri = getUri(collection, rkey); 108 + const check = store.checks.get(checkUri); 109 + if (!check) continue; 110 + const serviceUri = check.record.forService as ServiceUri; 111 + const service = store.services.get(serviceUri); 111 112 if (service) { 112 113 service.checks.delete(rkey); 113 - store.services.set(parsedServiceAtUri.rkey, service); 114 + store.services.set(serviceUri, service); 114 115 } 115 - store.checks.delete(rkey); 116 + store.checks.delete(checkUri); 117 + break; 118 + } 119 + case "systems.gaze.barometer.state": { 120 + store.states.delete(getUri(collection, rkey)); 116 121 break; 117 122 } 118 123 }
+95 -86
proxy/src/routes/push.ts
··· 1 - import { err, expect, getRecord, ok, putRecord, type Result } from "../utils"; 1 + import { 2 + err, 3 + expect, 4 + getRecord, 5 + getUri, 6 + ok, 7 + putRecord, 8 + type CheckUri, 9 + type CollectionUri, 10 + type Result, 11 + type ServiceUri, 12 + type StateUri, 13 + } from "../utils"; 2 14 import { 3 15 parseCanonicalResourceUri, 4 16 safeParse, ··· 6 18 type ParsedCanonicalResourceUri, 7 19 type ResourceUri, 8 20 } from "@atcute/lexicons"; 9 - import store, { type Service } from "../store"; 21 + import store, { type Check, type Service, type State } from "../store"; 10 22 import { systemctlShow } from "../systemd"; 11 23 import { config } from "../config"; 12 24 import { now as generateTid } from "@atcute/tid"; 13 25 import * as v from "@atcute/lexicons/validations"; 14 26 import type { SystemsGazeBarometerService } from "barometer-lexicon"; 27 + import type { SystemsGazeBarometerState } from "barometer-lexicon"; 15 28 16 29 // this is hacky but we want to make forService be optional so its okay 17 30 const StateSchemaSubset = v.record( 18 31 v.tidString(), 19 32 v.object({ 20 - $type: v.literal("systems.gaze.barometer.state"), 21 - changedAt: v.datetimeString(), 33 + changedAt: v.optional(v.datetimeString()), 22 34 forService: v.optional(v.resourceUriString()), 23 35 generatedBy: v.optional(v.resourceUriString()), 24 36 reason: v.optional(v.string()), 25 - from: v.literalEnum([ 26 - "systems.gaze.barometer.status.degraded", 27 - "systems.gaze.barometer.status.healthy", 28 - "systems.gaze.barometer.status.unknown", 29 - ]), 30 - to: v.literalEnum([ 37 + state: v.literalEnum([ 31 38 "systems.gaze.barometer.status.degraded", 32 39 "systems.gaze.barometer.status.healthy", 33 40 ]), ··· 57 64 return ok(json as PushRequest); 58 65 }; 59 66 60 - const badRequest = <Error extends { msg: string }>(error: Error) => { 61 - return new Response(JSON.stringify(error), { status: 400 }); 67 + const error = <Error extends { msg: string }>( 68 + error: Error, 69 + status: number = 400, 70 + ) => { 71 + return new Response(JSON.stringify(error), { status }); 62 72 }; 63 73 export const POST = async (req: Bun.BunRequest) => { 64 74 const maybeData = parsePushRequest(await req.json()); 65 75 if (!maybeData.ok) { 66 - return badRequest({ 76 + return error({ 67 77 msg: `invalid request: ${maybeData.error}`, 68 78 }); 69 79 } 70 80 const data = maybeData.value; 71 81 72 82 let service: Service | undefined = undefined; 73 - let serviceAtUri: ResourceUri; 74 - let parsedServiceAtUri: ParsedCanonicalResourceUri; 83 + let serviceAtUri: ServiceUri | undefined; 75 84 if (data.state.forService) { 76 - parsedServiceAtUri = expect( 77 - parseCanonicalResourceUri(data.state.forService), 78 - ); 79 - service = store.services.get(parsedServiceAtUri.rkey); 80 - if (!service) { 81 - let serviceRecord = await getRecord( 82 - "systems.gaze.barometer.service", 83 - parsedServiceAtUri.rkey, 84 - ); 85 - if (!serviceRecord.ok) { 86 - return badRequest({ 87 - msg: `service was not found or is invalid: ${serviceRecord.error}`, 88 - }); 89 - } 90 - service = { 91 - record: serviceRecord.value, 92 - checks: new Set(), 93 - }; 94 - store.services.set(parsedServiceAtUri.rkey, service); 95 - } 96 - serviceAtUri = data.state.forService; 85 + serviceAtUri = data.state.forService as ServiceUri; 86 + const maybeService = await store.getOrFetch(serviceAtUri); 87 + if (!maybeService.ok) 88 + return error({ 89 + msg: `could not fetch service: ${maybeService.error}`, 90 + }); 91 + service = maybeService.value; 97 92 } else if (data.serviceName) { 98 - const serviceInfo = await systemctlShow(data.serviceName); 99 - if (serviceInfo.ok) { 100 - const record: SystemsGazeBarometerService.Main = { 101 - $type: "systems.gaze.barometer.service", 102 - name: data.serviceName, 103 - description: serviceInfo.value.description, 104 - hostedBy: `at://${config.repoDid}/systems.gaze.barometer.host/${store.hostname}`, 105 - }; 106 - const rkey = generateTid(); 107 - const putAt = await putRecord(record, rkey); 108 - data.state.forService = putAt.uri; 109 - service = { 110 - record, 111 - checks: new Set(), 112 - }; 113 - store.services.set(rkey, service); 114 - serviceAtUri = putAt.uri; 115 - parsedServiceAtUri = expect(parseCanonicalResourceUri(putAt.uri)); 116 - } else { 117 - return badRequest({ 118 - msg: `could not fetch service from systemd: ${serviceInfo.error}`, 93 + const maybeService = await store.getServiceFromSystemd(data.serviceName); 94 + if (!maybeService.ok) 95 + return error({ 96 + msg: `could not fetch service from systemd: ${maybeService.error}`, 119 97 }); 120 - } 98 + const [uri, srv] = maybeService.value; 99 + serviceAtUri = uri; 100 + service = srv; 121 101 } else { 122 - return badRequest({ 102 + return error({ 123 103 msg: `either 'state.forService' or 'serviceName' must be provided`, 124 104 }); 125 105 } 126 106 107 + let check: Check | undefined = undefined; 127 108 if (data.state.generatedBy) { 128 - const checkAtUri = expect( 129 - parseCanonicalResourceUri(data.state.generatedBy), 109 + const maybeCheck = await store.getOrFetch( 110 + data.state.generatedBy as CheckUri, 130 111 ); 131 - let check = store.checks.get(checkAtUri.rkey); 132 - if (!check) { 133 - let checkRecord = await getRecord( 134 - "systems.gaze.barometer.check", 135 - checkAtUri.rkey, 136 - ); 137 - if (!checkRecord.ok) { 138 - return badRequest({ 139 - msg: `check record not found or is invalid: ${checkRecord.error}`, 140 - }); 141 - } 142 - check = { 143 - record: checkRecord.value, 144 - }; 145 - store.checks.set(checkAtUri.rkey, check); 146 - } 147 - if (check.record.forService !== serviceAtUri) { 148 - return badRequest({ 112 + if (!maybeCheck.ok) return error({ msg: maybeCheck.error }); 113 + check = maybeCheck.value; 114 + if (check.record.forService !== serviceAtUri) 115 + return error({ 149 116 msg: `check record does not point to the same service as the state record service`, 150 117 }); 151 - } 152 118 // update services with check 153 - service.checks.add(checkAtUri.rkey); 154 - store.services.set(parsedServiceAtUri.rkey, service); 119 + service.checks.add(check.rkey); 120 + store.services.set(serviceAtUri, service); 155 121 } 156 122 157 - const result = await putRecord( 158 - { ...data.state, forService: data.state.forService! }, 159 - generateTid(), 160 - ); 123 + // get current state uri 124 + const currentStateUri = 125 + check && check.record.currentState 126 + ? check.record.currentState 127 + : service.record.currentState; 128 + 129 + if (currentStateUri) { 130 + // fetch current state 131 + const record = await store.getOrFetch(currentStateUri as StateUri); 132 + if (!record.ok) return error({ msg: record.error }); 133 + const currentState = record.value; 134 + 135 + // check if the state has changed 136 + if (currentState.record.state === data.state.state) 137 + return error( 138 + { 139 + msg: `state can't be the same as the latest state`, 140 + }, 141 + 208, 142 + ); 143 + } 144 + 145 + const stateRecord: SystemsGazeBarometerState.Main = { 146 + $type: "systems.gaze.barometer.state", 147 + ...data.state, 148 + forService: serviceAtUri, 149 + changedAt: data.state.changedAt ?? new Date().toISOString(), 150 + previous: currentStateUri, 151 + }; 152 + const rkey = generateTid(); 153 + const result = await putRecord(stateRecord, rkey); 154 + 155 + // store committed state in "cache" 156 + store.states.set(result.uri as StateUri, { record: stateRecord, rkey }); 157 + 158 + // update check with new state url 159 + if (check) { 160 + check.record.currentState = result.uri; 161 + store.checks.set(getUri("systems.gaze.barometer.check", check.rkey), check); 162 + await putRecord(check.record, check.rkey); 163 + } else { 164 + // update service with new state url 165 + service.record.currentState = result.uri; 166 + store.services.set(serviceAtUri, service); 167 + await putRecord(service.record, service.rkey); 168 + } 169 + 161 170 return new Response(JSON.stringify({ cid: result.cid, uri: result.uri })); 162 171 };
+103 -5
proxy/src/store.ts
··· 1 1 import os from "os"; 2 - import type { RecordKey } from "@atcute/lexicons"; 2 + import { parseCanonicalResourceUri, type RecordKey } from "@atcute/lexicons"; 3 3 import type { 4 4 SystemsGazeBarometerCheck, 5 5 SystemsGazeBarometerHost, 6 6 SystemsGazeBarometerService, 7 + SystemsGazeBarometerState, 7 8 } from "barometer-lexicon"; 9 + import { 10 + err, 11 + expect, 12 + getRecord, 13 + ok, 14 + putRecord, 15 + type CheckUri, 16 + type CollectionUri, 17 + type Result, 18 + type ServiceUri, 19 + type StateUri, 20 + } from "./utils"; 21 + import { systemctlShow } from "./systemd"; 22 + import { config } from "./config"; 23 + import { now as generateTid } from "@atcute/tid"; 8 24 25 + export interface State { 26 + rkey: RecordKey; 27 + record: SystemsGazeBarometerState.Main; 28 + } 9 29 export interface Check { 30 + rkey: RecordKey; 10 31 record: SystemsGazeBarometerCheck.Main; 11 32 } 12 33 export interface Service { 13 34 checks: Set<RecordKey>; 35 + rkey: RecordKey; 14 36 record: SystemsGazeBarometerService.Main; 15 37 } 16 38 17 39 class Store { 18 - services; 19 - checks; 40 + services: Map<ServiceUri, Service>; 41 + checks: Map<CheckUri, Check>; 42 + states: Map<StateUri, State>; 20 43 host: SystemsGazeBarometerHost.Main | null; 21 44 hostname: string; 22 45 23 46 constructor() { 24 - this.services = new Map<RecordKey, Service>(); 25 - this.checks = new Map<RecordKey, Check>(); 47 + this.services = new Map(); 48 + this.checks = new Map(); 49 + this.states = new Map(); 26 50 this.host = null; 27 51 this.hostname = os.hostname(); 28 52 } 53 + 54 + getOrFetch = async < 55 + Nsid extends 56 + | "systems.gaze.barometer.state" 57 + | "systems.gaze.barometer.check" 58 + | "systems.gaze.barometer.service", 59 + Uri extends CollectionUri<Nsid>, 60 + >( 61 + uri: Uri, 62 + ): Promise< 63 + Result< 64 + Uri extends StateUri 65 + ? State 66 + : Uri extends CheckUri 67 + ? Check 68 + : Uri extends ServiceUri 69 + ? Service 70 + : never, 71 + string 72 + > 73 + > => { 74 + const parsedUri = expect(parseCanonicalResourceUri(uri)); 75 + const nsid = parsedUri.collection; 76 + const record = await getRecord(nsid as Nsid, parsedUri.rkey); 77 + if (!record.ok) 78 + return err(`record not found or is invalid: ${record.error}`); 79 + const data = { 80 + record: record.value, 81 + rkey: parsedUri.rkey, 82 + }; 83 + 84 + switch (nsid) { 85 + case "systems.gaze.barometer.state": 86 + this.states.set(uri as StateUri, data as State); 87 + return ok(data as any); 88 + case "systems.gaze.barometer.check": 89 + this.checks.set(uri as CheckUri, data as Check); 90 + return ok(data as any); 91 + case "systems.gaze.barometer.service": 92 + this.services.set( 93 + uri as ServiceUri, 94 + { checks: new Set(), ...data } as Service, 95 + ); 96 + return ok(data as any); 97 + default: 98 + throw new Error(`unsupported namespace: ${nsid}`); 99 + } 100 + }; 101 + 102 + getServiceFromSystemd = async ( 103 + serviceName: string, 104 + ): Promise<Result<[ServiceUri, Service], string>> => { 105 + const serviceInfo = await systemctlShow(serviceName); 106 + if (serviceInfo.ok) { 107 + const record: SystemsGazeBarometerService.Main = { 108 + $type: "systems.gaze.barometer.service", 109 + name: serviceName, 110 + description: serviceInfo.value.description, 111 + hostedBy: `at://${config.repoDid}/systems.gaze.barometer.host/${store.hostname}`, 112 + }; 113 + const rkey = generateTid(); 114 + const putAt = await putRecord(record, rkey); 115 + const serviceUri = putAt.uri as ServiceUri; 116 + const service: Service = { 117 + record, 118 + checks: new Set(), 119 + rkey, 120 + }; 121 + store.services.set(serviceUri, service); 122 + return ok([serviceUri, service]); 123 + } else { 124 + return err(`could not fetch service from systemd: ${serviceInfo.error}`); 125 + } 126 + }; 29 127 } 30 128 31 129 const store = new Store();
+26 -12
proxy/src/utils.ts
··· 3 3 import { config } from "./config"; 4 4 import { ok as clientOk } from "@atcute/client"; 5 5 import { atpClient } from "."; 6 + import { now as generateTid } from "@atcute/tid"; 7 + import type { AtprotoDid } from "@atcute/lexicons/syntax"; 8 + 9 + export type CollectionUri<Nsid extends string> = 10 + `at://${AtprotoDid}/${Nsid}/${RecordKey}`; 11 + export type StateUri = CollectionUri<"systems.gaze.barometer.state">; 12 + export type CheckUri = CollectionUri<"systems.gaze.barometer.check">; 13 + export type ServiceUri = CollectionUri<"systems.gaze.barometer.service">; 14 + 15 + export const getUri = < 16 + Collection extends 17 + | "systems.gaze.barometer.state" 18 + | "systems.gaze.barometer.check" 19 + | "systems.gaze.barometer.service", 20 + >( 21 + collection: Collection, 22 + rkey: RecordKey, 23 + ): CollectionUri<Collection> => { 24 + return `at://${config.repoDid}/${collection}/${rkey}`; 25 + }; 6 26 7 27 export type Result<T, E> = 8 28 | { ··· 46 66 rkey, 47 67 }, 48 68 }); 49 - if (!maybeRecord.ok) { 69 + if (!maybeRecord.ok) 50 70 return err(maybeRecord.data.message ?? maybeRecord.data.error); 51 - } 52 71 const maybeTyped = safeParse( 53 72 BarometerSchemas[collection], 54 73 maybeRecord.data.value, 55 74 ); 56 - if (!maybeTyped.ok) { 57 - return err(maybeTyped.message); 58 - } 75 + if (!maybeTyped.ok) return err(maybeTyped.message); 59 76 return maybeTyped; 60 77 }; 61 78 ··· 63 80 Collection extends keyof typeof BarometerSchemas, 64 81 >( 65 82 record: InferOutput<(typeof BarometerSchemas)[Collection]>, 66 - rkey: RecordKey, 83 + rkey: RecordKey | null = null, 67 84 ) => { 68 85 return await clientOk( 69 86 atpClient.post("com.atproto.repo.putRecord", { ··· 71 88 collection: record["$type"], 72 89 repo: config.repoDid, 73 90 record, 74 - rkey, 91 + rkey: rkey ?? generateTid(), 75 92 }, 76 93 }), 77 94 ); ··· 95 112 async (req, srv) => { 96 113 for (const fn of fns) { 97 114 const result = await fn(req); 98 - if (result instanceof Response) { 99 - return result; 100 - } else { 101 - req = result; 102 - } 115 + if (result instanceof Response) return result; 116 + else req = result; 103 117 } 104 118 return route(req, srv); 105 119 };