feat: Add a lot of stuff

This commit is contained in:
Wanjohi
2025-10-25 22:11:33 +03:00
parent a3ee9aadd9
commit c5d0242d50
85 changed files with 6718 additions and 1377 deletions

34
packages/core/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
packages/core/README.md Normal file
View File

@@ -0,0 +1,15 @@
# core
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,29 @@
{
"name": "@nestri/core",
"type": "module",
"version": "0.0.0",
"sideEffects": false,
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "^0.31.5"
},
"files": [
"./tsconfig.base.json"
],
"exports": {
"./*": "./src/*.ts",
"./tsconfig.base": "./tsconfig.base.json"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"drizzle-orm": "^0.44.6",
"ioredis": "^5.8.2",
"postgres": "^3.4.7",
"ulid": "^3.0.1",
"zod": "catalog:",
"zod-openapi": "^5.4.3"
}
}

View File

@@ -0,0 +1,89 @@
import { Log } from "./utils/log.js";
import { Context } from "./context.js";
import { ErrorCodes, VisibleError } from "./error.js";
export namespace Actor {
export interface Member {
type: "member";
properties: {
memberID: string;
};
}
export interface Team {
type: "team";
properties: {
teamID: string;
email: string;
};
}
export interface System {
type: "system";
properties: {
teamID: string;
memberID: string;
};
}
export interface Token {
type: "token";
properties: {
teamID: string;
tokenID: string;
};
}
export interface Public {
type: "public";
properties: {};
}
export type Info = Team | Member | Public | Token | System;
export const ctx = Context.create<Info>();
export function teamID() {
const actor = ctx.use();
if ("teamID" in actor.properties) return actor.properties.teamID;
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
`You don't have permission to access this resource.`,
);
}
export function use() {
try {
return ctx.use();
} catch {
return { type: "public", properties: {} } as Public;
}
}
export function assert<T extends Info["type"]>(type: T) {
const actor = use();
if (actor.type !== type)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
`Actor is not "${type}"`,
);
return actor as Extract<Info, { type: T }>;
}
export function provide<
T extends Info["type"],
Next extends (...args: any) => any,
>(type: T, properties: Extract<Info, { type: T }>["properties"], fn: Next) {
return ctx.provide({ type, properties } as any, () =>
Log.provide(
{
actor: type,
...properties,
},
fn,
),
);
}
}

View File

@@ -0,0 +1,27 @@
import { teamTable } from "../team/team.sql.js";
import { pgTable, varchar } from "drizzle-orm/pg-core";
import { id, timestamps, ulid } from "../drizzle/types.js";
export const apiAppTable = pgTable("api_app", {
...id,
...timestamps,
name: varchar("name", { length: 255 }).notNull(),
secret: varchar("secret", { length: 255 }).notNull(),
redirectURI: varchar("redirect", { length: 255 }).notNull(),
teamID: ulid("team_id")
.references(() => teamTable.id, {
onDelete: "cascade",
})
.notNull(),
});
export const apiPersonalTokenTable = pgTable("api_personal_token", {
...id,
...timestamps,
token: varchar("token", { length: 255 }).notNull(),
teamID: ulid("team_id")
.references(() => teamTable.id, {
onDelete: "cascade",
})
.notNull(),
});

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
import { Examples } from "../examples.js";
export namespace App {
export const Info = z
.object({
id: z.string().meta({
description: "The unique identifier of the App",
example: Examples.App.id,
}),
name: z.string().meta({
description: "The name of the App",
example: Examples.App.name,
}),
redirectURI: z.url().meta({
description: "Redirect endpoint URL of the App",
example: Examples.App.redirectURI,
}),
secret: z.string().meta({
description: "Auth2.0 client secret key of the App (obfuscated)",
example: Examples.App.secret,
}),
})
.meta({
ref: "App",
description: "Represents an App for OAuth2.0 authentication",
example: Examples.App,
});
export type Info = z.infer<typeof Info>;
}

View File

@@ -0,0 +1,7 @@
import { App as ApiApp } from "./app.js";
import { Personal as ApiPersonal } from "./personal.js";
export namespace Api {
export const App = ApiApp;
export const Personal = ApiPersonal;
}

View File

@@ -0,0 +1,45 @@
import { z } from "zod/v4";
import { eq } from "drizzle-orm";
import { fn } from "../utils/fn.js";
import { Examples } from "../examples.js";
import { Database } from "../drizzle/index.js";
import { apiPersonalTokenTable } from "./api.sql.js";
export namespace Personal {
export const Info = z
.object({
id: z.string().meta({
description: "The unique identifier of the personal access token",
example: Examples.Token.id,
}),
created: z.string().meta({
description: "The creation timestamp of the personal access token",
example: Examples.Token.created,
}),
token: z.string().meta({
description:
"A personal access token used to access the Nestri API. This is used to do things on behalf of the user.",
example: Examples.Token.token,
}),
})
.meta({
ref: "PersonalToken",
description: "Represents a personal access token for API authentication",
example: Examples.Token,
});
export type Info = z.infer<typeof Info>;
export const fromToken = fn(Info.shape.token, async (token: string) =>
Database.transaction(async (tx) => {
return tx
.select({
id: apiPersonalTokenTable.id,
teamID: apiPersonalTokenTable.teamID,
})
.from(apiPersonalTokenTable)
.where(eq(apiPersonalTokenTable.token, token))
.then((rows) => rows.at(0));
}),
);
}

View File

@@ -0,0 +1,21 @@
import { AsyncLocalStorage } from "node:async_hooks";
export namespace Context {
export class NotFound extends Error {}
export function create<T>() {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new Error("No context available");
}
return result;
},
provide<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}
}

View File

@@ -0,0 +1,90 @@
import postgres from "postgres";
import { Context } from "../context.js";
import { ExtractTablesWithRelations } from "drizzle-orm";
import { PgTransaction, PgTransactionConfig } from "drizzle-orm/pg-core";
import { PostgresJsQueryResultHKT, drizzle } from "drizzle-orm/postgres-js";
export namespace Database {
const { DATABASE_URL } = process.env;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
const sql = postgres(DATABASE_URL, {
max: 20,
idle_timeout: 60,
});
const client = drizzle(sql);
export type Transaction = PgTransaction<
PostgresJsQueryResultHKT,
Record<string, never>,
ExtractTablesWithRelations<Record<string, never>>
>;
export type TxOrDb = Transaction | typeof client;
const TransactionContext = Context.create<{
tx: TxOrDb;
effects: (() => void | Promise<void>)[];
}>();
export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
try {
const { tx } = TransactionContext.use();
return tx.transaction(callback);
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = [];
const result = await TransactionContext.provide(
{
effects,
tx: client,
},
() => callback(client),
);
await Promise.all(effects.map((x) => x()));
return result;
}
throw err;
}
}
export async function fn<Input, T>(
callback: (input: Input, trx: TxOrDb) => Promise<T>,
) {
return (input: Input) => use(async (tx) => callback(input, tx));
}
export async function effect(effect: () => any | Promise<any>) {
try {
const { effects } = TransactionContext.use();
effects.push(effect);
} catch {
await effect();
}
}
export async function transaction<T>(
callback: (tx: TxOrDb) => Promise<T>,
config?: PgTransactionConfig,
) {
try {
const { tx } = TransactionContext.use();
return callback(tx);
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = [];
const result = await client.transaction(async (tx) => {
return TransactionContext.provide({ tx, effects }, () =>
callback(tx),
);
}, config);
await Promise.all(effects.map((x) => x()));
return result;
}
throw err;
}
}
}

View File

@@ -0,0 +1,39 @@
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
export const ulid = (name: string) => char(name, { length: 26 + 5 });
export const id = {
get id() {
return ulid("id").primaryKey().notNull();
},
};
export const teamID = {
get id() {
return ulid("id").notNull();
},
get teamID() {
return ulid("team_id").notNull();
},
};
export const memberID = {
get id() {
return ulid("id").notNull();
},
get userID() {
return ulid("member_id").notNull();
},
};
export const utc = (name: string) =>
rawTs(name, {
withTimezone: true,
// mode: "date"
});
export const timestamps = {
timeCreated: utc("time_created").notNull().defaultNow(),
timeUpdated: utc("time_updated").notNull().defaultNow(),
timeDeleted: utc("time_deleted"),
};

145
packages/core/src/error.ts Normal file
View File

@@ -0,0 +1,145 @@
import { z } from "zod/v4";
/**
* Standard error response schema used for OpenAPI documentation
*/
export const ErrorResponse = z
.object({
type: z
.enum([
"validation",
"authentication",
"forbidden",
"not_found",
"already_exists",
"rate_limit",
"internal",
])
.meta({
description: "The error type category",
examples: ["validation", "authentication"],
}),
code: z.string().meta({
description: "Machine-readable error code identifier",
examples: ["invalid_parameter", "missing_required_field", "unauthorized"],
}),
message: z.string().meta({
description: "Human-readable error message",
examples: ["The request was invalid", "Authentication required"],
}),
param: z
.string()
.optional()
.meta({
description: "The parameter that caused the error (if applicable)",
examples: ["email", "user_id", "team_id"],
}),
details: z.any().optional().meta({
description: "Additional error context information",
}),
})
.meta({ ref: "ErrorResponse" });
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
/**
* Standardized error codes for the API
*/
export const ErrorCodes = {
// Validation errors (400)
Validation: {
MISopenapiSING_REQUIRED_FIELD: "missing_required_field",
ALREADY_EXISTS: "resource_already_exists",
TEAM_ALREADY_EXISTS: "team_already_exists",
INVALID_PARAMETER: "invalid_parameter",
INVALID_FORMAT: "invalid_format",
INVALID_STATE: "invalid_state",
IN_USE: "resource_in_use",
},
// Authentication errors (401)
Authentication: {
UNAUTHORIZED: "unauthorized",
INVALID_TOKEN: "invalid_token",
EXPIRED_TOKEN: "expired_token",
INVALID_CREDENTIALS: "invalid_credentials",
},
// Permission errors (403)
Permission: {
FORBIDDEN: "forbidden",
INSUFFICIENT_PERMISSIONS: "insufficient_permissions",
ACCOUNT_RESTRICTED: "account_restricted",
},
// Resource not found errors (404)
NotFound: {
RESOURCE_NOT_FOUND: "resource_not_found",
},
// Rate limit errors (429)
RateLimit: {
TOO_MANY_REQUESTS: "too_many_requests",
QUOTA_EXCEEDED: "quota_exceeded",
},
// Server errors (500)
Server: {
INTERNAL_ERROR: "internal_error",
SERVICE_UNAVAILABLE: "service_unavailable",
DEPENDENCY_FAILURE: "dependency_failure",
},
};
/**
* Standard error that will be exposed to clients through API responses
*/
export class VisibleError extends Error {
constructor(
public type: ErrorResponseType["type"],
public code: string,
public message: string,
public param?: string,
public details?: any,
) {
super(message);
}
/**
* Convert this error to an HTTP status code
*/
public statusCode(): number {
switch (this.type) {
case "validation":
return 400;
case "authentication":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "already_exists":
return 409;
case "rate_limit":
return 429;
case "internal":
return 500;
}
}
/**
* Convert this error to a standard response object
*/
public toResponse(): ErrorResponseType {
const response: ErrorResponseType = {
type: this.type,
code: this.code,
message: this.message,
};
if (this.param) response.param = this.param;
if (this.details) response.details = this.details;
return response;
}
}

View File

@@ -0,0 +1,138 @@
import { Prettify } from "../utils/types.js";
export namespace event {
export type Definition = {
type: string;
$input: any;
$output: any;
$metadata: any;
$payload: any;
create: (...args: any[]) => Promise<any>;
};
export function builder<
Metadata extends
| ((type: string, properties: any) => any)
| Parameters<Validator>[0],
Validator extends (schema: any) => (input: any) => any,
>(input: { validator: Validator; metadata?: Metadata }) {
const validator = input.validator;
const fn = function event<
Type extends string,
Schema extends Parameters<Validator>[0],
>(type: Type, schema: Schema) {
type MetadataOutput = Metadata extends (
type: string,
properties: any,
) => any
? ReturnType<Metadata>
: // @ts-expect-error
inferParser<Metadata>["out"];
type Payload = Prettify<{
type: Type;
properties: Parsed["out"];
metadata: MetadataOutput;
}>;
type Parsed = inferParser<Schema>;
type Create = Metadata extends (type: string, properties: any) => any
? (properties: Parsed["in"]) => Promise<Payload>
: (
properties: Parsed["in"],
// @ts-expect-error
metadata: inferParser<Metadata>["in"],
) => Promise<Payload>;
const validate = validator(schema);
async function create(properties: any, metadata?: any) {
metadata = input.metadata
? typeof input.metadata === "function"
? input.metadata(type, properties)
: input.metadata(metadata)
: {};
properties = validate(properties);
return {
type,
properties,
metadata,
};
}
return {
create: create as Create,
type,
$input: {} as Parsed["in"],
$output: {} as Parsed["out"],
$payload: {} as Payload,
$metadata: {} as MetadataOutput,
} satisfies Definition;
};
fn.coerce = <Events extends Definition>(
_events: Events | Events[],
raw: any,
): {
[K in Events["type"]]: Extract<Events, { type: K }>["$payload"];
}[Events["type"]] => {
return raw;
};
return fn;
}
// Taken from tRPC
type ParserZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
type ParserValibotEsque<TInput, TParsedInput> = {
_types?: {
input: TInput;
output: TParsedInput;
};
};
type ParserMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
type ParserSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
type ParserCustomValidatorEsque<TInput> = (
input: unknown,
) => Promise<TInput> | TInput;
type ParserYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
type ParserScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type ParserWithoutInput<TInput> =
| ParserCustomValidatorEsque<TInput>
| ParserMyZodEsque<TInput>
| ParserScaleEsque<TInput>
| ParserSuperstructEsque<TInput>
| ParserYupEsque<TInput>;
export type ParserWithInputOutput<TInput, TParsedInput> =
| ParserZodEsque<TInput, TParsedInput>
| ParserValibotEsque<TInput, TParsedInput>;
export type Parser =
| ParserWithInputOutput<any, any>
| ParserWithoutInput<any>;
export type inferParser<TParser extends Parser> =
TParser extends ParserWithInputOutput<infer $TIn, infer $TOut>
? {
in: $TIn;
out: $TOut;
}
: TParser extends ParserWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
}

View File

@@ -0,0 +1,12 @@
import { event } from "./event.js";
import { Actor } from "../actor.js";
import { ZodValidator } from "./validator.js";
export const createEvent = event.builder({
validator: ZodValidator,
metadata() {
return {
actor: Actor.use(),
};
},
});

View File

@@ -0,0 +1,9 @@
import { type ZodType, z } from "zod/v4";
export function ZodValidator<Schema extends ZodType>(
schema: Schema,
): (input: z.input<Schema>) => z.output<Schema> {
return (input) => {
return schema.parse(input);
};
}

View File

@@ -0,0 +1,32 @@
import { prefixes } from "./utils/id.js";
export namespace Examples {
export const Id = (prefix: keyof typeof prefixes) =>
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
export const Team = {
id: Id("team"),
email: "email@example.com",
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
};
export const Member = {
id: Id("member"),
name: "John Doe",
role: "adult" as const, // Role on the team, adult or child
teamID: Team.id, // FK to Teams
};
export const Token = {
id: Id("apiPersonal"),
token: "nst_test_******XXXX",
created: new Date("2024-06-29 19:36:19.000"),
};
export const App = {
id: Id("apiApp"),
secret: "sec_******XXXX",
name: "Example App",
redirectURI: "https://web.example.com/v1/callback",
};
}

View File

@@ -0,0 +1,32 @@
import { z } from "zod/v4";
import { Examples } from "../examples.js";
import { MemberRole } from "./member.sql.js";
export namespace Member {
export const Info = z
.object({
id: z.string().meta({
description: "The unique identifier of the member",
example: Examples.Member.id,
}),
name: z.string().meta({
description: "The name of the member",
example: Examples.Member.name,
}),
teamID: z.string().meta({
description: "The unique identifier of the team this member belongs to",
example: Examples.Member.teamID,
}),
role: z.enum(MemberRole.enumValues).meta({
description: "The role of the member within the team",
example: Examples.Member.role,
}),
})
.meta({
ref: "Member",
description: "Represents a member of a team",
example: Examples.Member,
});
export type Info = z.infer<typeof Info>;
}

View File

@@ -0,0 +1,15 @@
import { teamTable } from "../team/team.sql.js";
import { pgEnum, pgTable } from "drizzle-orm/pg-core";
import { id, ulid, timestamps } from "../drizzle/types.js";
export const MemberRole = pgEnum("role", ["adult", "child"]);
export const memberTable = pgTable("members", {
...id,
...timestamps,
name: ulid("name").notNull(),
teamID: ulid("team_id").references(() => teamTable.id, {
onDelete: "cascade",
}),
role: MemberRole("role").notNull(),
});

View File

@@ -0,0 +1,20 @@
import { z } from "zod/v4";
import { fn } from "../utils/fn.js";
import { Polar as PolarSdk } from "@polar-sh/sdk";
import { type Customer } from "@polar-sh/sdk/models/components/customer.js";
export namespace Polar {
export const client = new PolarSdk({
accessToken: process.env.POLAR_API_KEY || "",
server: process.env.APP_STAGE !== "production" ? "sandbox" : "production",
});
export const fromUserEmail = fn(z.email(), async (email) => {
const customers = await client.customers.list({ email });
if (customers.result.items.length === 0) {
return await client.customers.create({ email });
}
return customers.result.items[0] as Customer;
});
}

View File

@@ -0,0 +1,47 @@
import { Log } from "../utils/log.js";
import { Redis, type RedisOptions } from "ioredis";
export {
Redis,
Cluster,
type Callback,
type RedisOptions,
type Result,
type RedisCommander,
} from "ioredis";
const defaultOptions: Partial<RedisOptions> = {
retryStrategy: (times: number) => {
const delay = Math.min(times * 50, 1000);
return delay;
},
maxRetriesPerRequest: 20,
};
const logger = Log.create({ namespace: "redis" });
export namespace RedisClient {
export function create(
options: RedisOptions,
handlers?: { onError?: (err: Error) => void },
) {
const client = new Redis({
...defaultOptions,
...options,
});
client.on("error", (error) => {
if (handlers?.onError) {
handlers.onError(error);
} else {
logger.error(
new Error(
`Redis client error:${error} at keyPrefix:${options.keyPrefix}`,
),
);
}
});
return client;
}
}

View File

@@ -0,0 +1,755 @@
import {
Client,
type Redis,
type Callback,
type RedisOptions,
type Result,
} from "./client.js";
import { z } from "zod/v4";
import { ulid } from "ulid";
import { Log } from "../utils/log.js";
export interface MessageCatalogSchema {
[key: string]: z.ZodFirstPartySchemaTypes; //| z.ZodDiscriminatedUnion<any, any>;
}
export type MessageCatalogKey<TMessageCatalog extends MessageCatalogSchema> =
keyof TMessageCatalog;
export type MessageCatalogValue<
TMessageCatalog extends MessageCatalogSchema,
TKey extends MessageCatalogKey<TMessageCatalog>,
> = z.infer<TMessageCatalog[TKey]>;
export type AnyMessageCatalog = MessageCatalogSchema;
export type QueueItem<TMessageCatalog extends MessageCatalogSchema> = {
id: string;
job: MessageCatalogKey<TMessageCatalog>;
item: MessageCatalogValue<
TMessageCatalog,
MessageCatalogKey<TMessageCatalog>
>;
visibilityTimeoutMs: number;
attempt: number;
timestamp: Date;
deduplicationKey?: string;
};
export type AnyQueueItem = {
id: string;
job: string;
item: any;
visibilityTimeoutMs: number;
attempt: number;
timestamp: Date;
deduplicationKey?: string;
};
export class SimpleQueue<TMessageCatalog extends MessageCatalogSchema> {
name: string;
private redis: Redis;
private schema: TMessageCatalog;
private logger: ReturnType<typeof Log.create>;
constructor({
name,
schema,
redisOptions,
}: {
name: string;
schema: TMessageCatalog;
redisOptions: RedisOptions;
}) {
this.name = name;
this.logger = Log.create({ namespace: "SimpleQueue" });
this.redis = Client.create(
{
...redisOptions,
keyPrefix: `${redisOptions.keyPrefix ?? ""}{queue:${name}:}`,
retryStrategy(times) {
const delay = Math.min(times * 50, 1000);
return delay;
},
maxRetriesPerRequest: 20,
},
{
onError: (error) => {
this.logger.error(`RedisWorker queue redis client error:`, {
error,
keyPrefix: redisOptions.keyPrefix,
});
},
},
);
this.#registerCommands();
this.schema = schema;
}
async cancel(cancellationKey: string): Promise<void> {
await this.redis.set(
`cancellationKey:${cancellationKey}`,
"1",
"EX",
60 * 60 * 24,
); // 1 day
}
async enqueue({
id,
job,
item,
attempt,
availableAt,
visibilityTimeoutMs,
cancellationKey,
}: {
id?: string;
job: MessageCatalogKey<TMessageCatalog>;
item: MessageCatalogValue<
TMessageCatalog,
MessageCatalogKey<TMessageCatalog>
>;
attempt?: number;
availableAt?: Date;
visibilityTimeoutMs: number;
cancellationKey?: string;
}): Promise<void> {
try {
const score = availableAt ? availableAt.getTime() : Date.now();
const deduplicationKey = ulid();
const serializedItem = JSON.stringify({
job,
item,
visibilityTimeoutMs,
attempt,
deduplicationKey,
});
const result = cancellationKey
? await this.redis.enqueueItemWithCancellationKey(
`queue`,
`items`,
`cancellationKey:${cancellationKey}`,
id ?? ulid(),
score,
serializedItem,
)
: await this.redis.enqueueItem(
`queue`,
`items`,
id ?? ulid(),
score,
serializedItem,
);
if (result !== 1) {
throw new Error("Enqueue operation failed");
}
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.enqueue(): error enqueuing`, {
queue: this.name,
error: e,
id,
item,
});
throw e;
}
}
async enqueueOnce({
id,
job,
item,
attempt,
availableAt,
visibilityTimeoutMs,
}: {
id: string;
job: MessageCatalogKey<TMessageCatalog>;
item: MessageCatalogValue<
TMessageCatalog,
MessageCatalogKey<TMessageCatalog>
>;
attempt?: number;
availableAt?: Date;
visibilityTimeoutMs: number;
}): Promise<boolean> {
if (!id) {
throw new Error("enqueueOnce requires an id");
}
try {
const score = availableAt ? availableAt.getTime() : Date.now();
const deduplicationKey = ulid();
const serializedItem = JSON.stringify({
job,
item,
visibilityTimeoutMs,
attempt,
deduplicationKey,
});
const result = await this.redis.enqueueItemOnce(
`queue`,
`items`,
id,
score,
serializedItem,
);
// 1 if inserted, 0 if already exists
return result === 1;
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.enqueueOnce(): error enqueuing`,
{
queue: this.name,
error: e,
id,
item,
},
);
throw e;
}
}
async dequeue(count: number = 1): Promise<Array<QueueItem<TMessageCatalog>>> {
const now = Date.now();
try {
const results = await this.redis.dequeueItems(
`queue`,
`items`,
now,
count,
);
if (!results || results.length === 0) {
return [];
}
const dequeuedItems: Array<QueueItem<TMessageCatalog>> = [];
for (const [id, serializedItem, score] of results) {
const parsedItem = JSON.parse(serializedItem) as any;
if (typeof parsedItem.job !== "string") {
this.logger.error(`Invalid item in queue`, {
queue: this.name,
id,
item: parsedItem,
});
continue;
}
const timestamp = new Date(Number(score));
const schema = this.schema[parsedItem.job];
if (!schema) {
this.logger.error(`Invalid item in queue, schema not found`, {
queue: this.name,
id,
item: parsedItem,
job: parsedItem.job,
timestamp,
availableJobs: Object.keys(this.schema),
});
continue;
}
const validatedItem = schema.safeParse(parsedItem.item);
if (!validatedItem.success) {
this.logger.error("Invalid item in queue", {
queue: this.name,
id,
item: parsedItem,
errors: validatedItem.error,
attempt: parsedItem.attempt,
timestamp,
});
continue;
}
const visibilityTimeoutMs = parsedItem.visibilityTimeoutMs as number;
dequeuedItems.push({
id,
job: parsedItem.job,
item: validatedItem.data,
visibilityTimeoutMs,
attempt: parsedItem.attempt ?? 0,
timestamp,
deduplicationKey: parsedItem.deduplicationKey,
});
}
return dequeuedItems;
} catch (e) {
this.logger.error(`SimpleQueue ${this.name}.dequeue(): error dequeuing`, {
queue: this.name,
error: e,
count,
});
throw e;
}
}
async ack(id: string, deduplicationKey?: string): Promise<void> {
try {
const result = await this.redis.ackItem(
`queue`,
`items`,
id,
deduplicationKey ?? "",
);
if (result !== 1) {
this.logger.debug(
`SimpleQueue ${this.name}.ack(): ack operation returned ${result}. This means it was not removed from the queue.`,
{
queue: this.name,
id,
deduplicationKey,
result,
},
);
}
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.ack(): error acknowledging item`,
{
queue: this.name,
error: e,
id,
deduplicationKey,
},
);
throw e;
}
}
async reschedule(id: string, availableAt: Date): Promise<void> {
await this.redis.zadd(`queue`, "XX", availableAt.getTime(), id);
}
async size({
includeFuture = false,
}: { includeFuture?: boolean } = {}): Promise<number> {
try {
if (includeFuture) {
// If includeFuture is true, return the total count of all items
return await this.redis.zcard(`queue`);
} else {
// If includeFuture is false, return the count of items available now
const now = Date.now();
return await this.redis.zcount(`queue`, "-inf", now);
}
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.size(): error getting queue size`,
{
queue: this.name,
error: e,
includeFuture,
},
);
throw e;
}
}
async getJob(id: string): Promise<QueueItem<TMessageCatalog> | null> {
const result = await this.redis.getJob(`queue`, `items`, id);
if (!result) {
return null;
}
const [_, score, serializedItem] = result;
const item = JSON.parse(serializedItem) as QueueItem<TMessageCatalog>;
return {
id,
job: item.job,
item: item.item,
visibilityTimeoutMs: item.visibilityTimeoutMs,
attempt: item.attempt ?? 0,
timestamp: new Date(Number(score)),
deduplicationKey: item.deduplicationKey ?? undefined,
};
}
async moveToDeadLetterQueue(id: string, errorMessage: string): Promise<void> {
try {
this.logger.debug(
`SimpleQueue ${this.name}.moveToDeadLetterQueue(): moving item to DLQ`,
{
queue: this.name,
id,
errorMessage,
},
);
const result = await this.redis.moveToDeadLetterQueue(
`queue`,
`items`,
`dlq`,
`dlq:items`,
id,
errorMessage,
);
if (result !== 1) {
throw new Error("Move to Dead Letter Queue operation failed");
}
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.moveToDeadLetterQueue(): error moving item to DLQ`,
{
queue: this.name,
error: e,
id,
errorMessage,
},
);
throw e;
}
}
async sizeOfDeadLetterQueue(): Promise<number> {
try {
return await this.redis.zcard(`dlq`);
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.dlqSize(): error getting DLQ size`,
{
queue: this.name,
error: e,
},
);
throw e;
}
}
async redriveFromDeadLetterQueue(id: string): Promise<void> {
try {
const result = await this.redis.redriveFromDeadLetterQueue(
`queue`,
`items`,
`dlq`,
`dlq:items`,
id,
);
if (result !== 1) {
throw new Error("Redrive from Dead Letter Queue operation failed");
}
} catch (e) {
this.logger.error(
`SimpleQueue ${this.name}.redriveFromDeadLetterQueue(): error redriving item from DLQ`,
{
queue: this.name,
error: e,
id,
},
);
throw e;
}
}
async close(): Promise<void> {
await this.redis.quit();
}
#registerCommands() {
this.redis.defineCommand("enqueueItem", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local id = ARGV[1]
local score = ARGV[2]
local serializedItem = ARGV[3]
redis.call('ZADD', queue, score, id)
redis.call('HSET', items, id, serializedItem)
return 1
`,
});
this.redis.defineCommand("enqueueItemWithCancellationKey", {
numberOfKeys: 3,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local cancellationKey = KEYS[3]
local id = ARGV[1]
local score = ARGV[2]
local serializedItem = ARGV[3]
-- if the cancellation key exists, return 1
if redis.call('EXISTS', cancellationKey) == 1 then
return 1
end
redis.call('ZADD', queue, score, id)
redis.call('HSET', items, id, serializedItem)
return 1
`,
});
this.redis.defineCommand("dequeueItems", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local now = tonumber(ARGV[1])
local count = tonumber(ARGV[2])
local result = redis.call('ZRANGEBYSCORE', queue, '-inf', now, 'WITHSCORES', 'LIMIT', 0, count)
if #result == 0 then
return {}
end
local dequeued = {}
for i = 1, #result, 2 do
local id = result[i]
local score = tonumber(result[i + 1])
if score > now then
break
end
local serializedItem = redis.call('HGET', items, id)
if serializedItem then
local item = cjson.decode(serializedItem)
local visibilityTimeoutMs = tonumber(item.visibilityTimeoutMs)
local invisibleUntil = now + visibilityTimeoutMs
redis.call('ZADD', queue, invisibleUntil, id)
table.insert(dequeued, {id, serializedItem, score})
else
-- Remove the orphaned queue entry if no corresponding item exists
redis.call('ZREM', queue, id)
end
end
return dequeued
`,
});
this.redis.defineCommand("getJob", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local jobId = ARGV[1]
local serializedItem = redis.call('HGET', items, jobId)
if serializedItem == false then
return nil
end
-- get the score from the queue sorted set
local score = redis.call('ZSCORE', queue, jobId)
return { jobId, score, serializedItem }
`,
});
this.redis.defineCommand("ackItem", {
numberOfKeys: 2,
lua: `
local queueKey = KEYS[1]
local itemsKey = KEYS[2]
local id = ARGV[1]
local deduplicationKey = ARGV[2]
-- Get the item from the hash
local item = redis.call('HGET', itemsKey, id)
if not item then
return -1
end
-- Only check deduplicationKey if a non-empty one was passed in
if deduplicationKey and deduplicationKey ~= "" then
local success, parsed = pcall(cjson.decode, item)
if success then
if parsed.deduplicationKey and parsed.deduplicationKey ~= deduplicationKey then
return 0
end
end
end
-- Remove from sorted set and hash
redis.call('ZREM', queueKey, id)
redis.call('HDEL', itemsKey, id)
return 1
`,
});
this.redis.defineCommand("moveToDeadLetterQueue", {
numberOfKeys: 4,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local dlq = KEYS[3]
local dlqItems = KEYS[4]
local id = ARGV[1]
local errorMessage = ARGV[2]
local item = redis.call('HGET', items, id)
if not item then
return 0
end
local parsedItem = cjson.decode(item)
parsedItem.errorMessage = errorMessage
local time = redis.call('TIME')
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
redis.call('ZREM', queue, id)
redis.call('HDEL', items, id)
redis.call('ZADD', dlq, now, id)
redis.call('HSET', dlqItems, id, cjson.encode(parsedItem))
return 1
`,
});
this.redis.defineCommand("redriveFromDeadLetterQueue", {
numberOfKeys: 4,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local dlq = KEYS[3]
local dlqItems = KEYS[4]
local id = ARGV[1]
local item = redis.call('HGET', dlqItems, id)
if not item then
return 0
end
local parsedItem = cjson.decode(item)
parsedItem.errorMessage = nil
local time = redis.call('TIME')
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
redis.call('ZREM', dlq, id)
redis.call('HDEL', dlqItems, id)
redis.call('ZADD', queue, now, id)
redis.call('HSET', items, id, cjson.encode(parsedItem))
return 1
`,
});
this.redis.defineCommand("enqueueItemOnce", {
numberOfKeys: 2,
lua: `
local queue = KEYS[1]
local items = KEYS[2]
local id = ARGV[1]
local score = ARGV[2]
local serializedItem = ARGV[3]
-- Only add if not exists
local added = redis.call('HSETNX', items, id, serializedItem)
if added == 1 then
redis.call('ZADD', queue, 'NX', score, id)
return 1
else
return 0
end
`,
});
}
}
declare module "ioredis" {
interface RedisCommander<Context> {
enqueueItem(
//keys
queue: string,
items: string,
//args
id: string,
score: number,
serializedItem: string,
callback?: Callback<number>,
): Result<number, Context>;
enqueueItemWithCancellationKey(
//keys
queue: string,
items: string,
cancellationKey: string,
//args
id: string,
score: number,
serializedItem: string,
callback?: Callback<number>,
): Result<number, Context>;
dequeueItems(
//keys
queue: string,
items: string,
//args
now: number,
count: number,
callback?: Callback<Array<[string, string, string]>>,
): Result<Array<[string, string, string]>, Context>;
ackItem(
queue: string,
items: string,
id: string,
deduplicationKey: string,
callback?: Callback<number>,
): Result<number, Context>;
redriveFromDeadLetterQueue(
queue: string,
items: string,
dlq: string,
dlqItems: string,
id: string,
callback?: Callback<number>,
): Result<number, Context>;
moveToDeadLetterQueue(
queue: string,
items: string,
dlq: string,
dlqItems: string,
id: string,
errorMessage: string,
callback?: Callback<number>,
): Result<number, Context>;
enqueueItemOnce(
queue: string,
items: string,
id: string,
score: number,
serializedItem: string,
callback?: Callback<number>,
): Result<number, Context>;
getJob(
queue: string,
items: string,
id: string,
callback?: Callback<[string, string, string] | null>,
): Result<[string, string, string] | null, Context>;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./json.js";
export * from "./types.js";
export * from "./schema.js";

View File

@@ -0,0 +1,118 @@
import { z } from "zod/v4";
const LiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
type Literal = z.infer<typeof LiteralSchema>;
export type DeserializedJson =
| Literal
| { [key: string]: DeserializedJson }
| DeserializedJson[];
export const DeserializedJsonSchema: z.ZodType<DeserializedJson> = z.lazy(() =>
z.union([
LiteralSchema,
z.array(DeserializedJsonSchema),
z.record(z.string(), DeserializedJsonSchema),
]),
);
const SerializableSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.date(),
z.undefined(),
z.symbol(),
]);
type Serializable = z.infer<typeof SerializableSchema>;
export type SerializableJson =
| Serializable
| { [key: string]: SerializableJson }
| SerializableJson[];
export const SerializableJsonSchema: z.ZodType<SerializableJson> = z.lazy(() =>
z.union([
SerializableSchema,
z.array(SerializableJsonSchema),
z.record(z.string(), SerializableJsonSchema),
]),
);
/**
* JSON Schema type definition - compatible with JSON Schema Draft 7
* Based on the JSONSchema7 type from @types/json-schema but defined inline to avoid import issues
*/
export interface JSONSchema {
$id?: string;
$ref?: string;
$schema?: string;
$comment?: string;
type?: JSONSchemaType | JSONSchemaType[];
enum?: any[];
const?: any;
// Number/Integer validations
multipleOf?: number;
maximum?: number;
exclusiveMaximum?: number;
minimum?: number;
exclusiveMinimum?: number;
// String validations
maxLength?: number;
minLength?: number;
pattern?: string;
format?: string;
// Array validations
items?: JSONSchema | JSONSchema[];
additionalItems?: JSONSchema | boolean;
maxItems?: number;
minItems?: number;
uniqueItems?: boolean;
contains?: JSONSchema;
// Object validations
maxProperties?: number;
minProperties?: number;
required?: string[];
properties?: Record<string, JSONSchema>;
patternProperties?: Record<string, JSONSchema>;
additionalProperties?: JSONSchema | boolean;
dependencies?: Record<string, JSONSchema | string[]>;
propertyNames?: JSONSchema;
// Conditional schemas
if?: JSONSchema;
then?: JSONSchema;
else?: JSONSchema;
// Boolean logic
allOf?: JSONSchema[];
anyOf?: JSONSchema[];
oneOf?: JSONSchema[];
not?: JSONSchema;
// Metadata
title?: string;
description?: string;
default?: any;
readOnly?: boolean;
writeOnly?: boolean;
examples?: any[];
// Additional properties for extensibility
[key: string]: any;
}
export type JSONSchemaType =
| "string"
| "number"
| "integer"
| "boolean"
| "object"
| "array"
| "null";

View File

@@ -0,0 +1,152 @@
export type SchemaZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export function isSchemaZodEsque<TInput, TParsedInput>(
schema: Schema,
): schema is SchemaZodEsque<TInput, TParsedInput> {
return (
typeof schema === "object" &&
"_def" in schema &&
"parse" in schema &&
"parseAsync" in schema &&
"safeParse" in schema
);
}
export type SchemaValibotEsque<TInput, TParsedInput> = {
schema: {
_types?: {
input: TInput;
output: TParsedInput;
};
};
};
export function isSchemaValibotEsque<TInput, TParsedInput>(
schema: Schema,
): schema is SchemaValibotEsque<TInput, TParsedInput> {
return typeof schema === "object" && "_types" in schema;
}
export type SchemaArkTypeEsque<TInput, TParsedInput> = {
inferIn: TInput;
infer: TParsedInput;
};
export function isSchemaArkTypeEsque<TInput, TParsedInput>(
schema: Schema,
): schema is SchemaArkTypeEsque<TInput, TParsedInput> {
return (
typeof schema === "object" && "_inferIn" in schema && "_infer" in schema
);
}
export type SchemaMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type SchemaSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type SchemaCustomValidatorEsque<TInput> = (
input: unknown,
) => Promise<TInput> | TInput;
export type SchemaYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type SchemaScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type SchemaWithoutInput<TInput> =
| SchemaCustomValidatorEsque<TInput>
| SchemaMyZodEsque<TInput>
| SchemaScaleEsque<TInput>
| SchemaSuperstructEsque<TInput>
| SchemaYupEsque<TInput>;
export type SchemaWithInputOutput<TInput, TParsedInput> =
| SchemaZodEsque<TInput, TParsedInput>
| SchemaValibotEsque<TInput, TParsedInput>
| SchemaArkTypeEsque<TInput, TParsedInput>;
export type Schema = SchemaWithInputOutput<any, any> | SchemaWithoutInput<any>;
export type inferSchema<TSchema extends Schema> =
TSchema extends SchemaWithInputOutput<infer $TIn, infer $TOut>
? {
in: $TIn;
out: $TOut;
}
: TSchema extends SchemaWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type inferSchemaIn<
TSchema extends Schema | undefined,
TDefault = unknown,
> = TSchema extends Schema ? inferSchema<TSchema>["in"] : TDefault;
export type inferSchemaOut<
TSchema extends Schema | undefined,
TDefault = unknown,
> = TSchema extends Schema ? inferSchema<TSchema>["out"] : TDefault;
export type SchemaParseFn<TType> = (value: unknown) => Promise<TType> | TType;
export type AnySchemaParseFn = SchemaParseFn<any>;
export function getSchemaParseFn<TType>(
procedureParser: Schema,
): SchemaParseFn<TType> {
const parser = procedureParser as any;
if (typeof parser === "function" && typeof parser.assert === "function") {
// ParserArkTypeEsque - arktype schemas shouldn't be called as a function because they return a union type instead of throwing
return parser.assert.bind(parser);
}
if (typeof parser === "function") {
// ParserValibotEsque (>= v0.31.0)
// ParserCustomValidatorEsque
return parser;
}
if (typeof parser.parseAsync === "function") {
// ParserZodEsque
return parser.parseAsync.bind(parser);
}
if (typeof parser.parse === "function") {
// ParserZodEsque
// ParserValibotEsque (< v0.13.0)
return parser.parse.bind(parser);
}
if (typeof parser.validateSync === "function") {
// ParserYupEsque
return parser.validateSync.bind(parser);
}
if (typeof parser.create === "function") {
// ParserSuperstructEsque
return parser.create.bind(parser);
}
if (typeof parser.assert === "function") {
// ParserScaleEsque
return (value) => {
parser.assert(value);
return value as TType;
};
}
throw new Error("Could not find a validator fn");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import { z } from "zod/v4";
import { fn } from "../utils/fn.js";
import { Examples } from "../examples.js";
import { teamTable } from "./team.sql.js";
import { createID } from "../utils/id.js";
import { Polar } from "../polar/index.js";
import { Database } from "../drizzle/index.js";
import { VisibleError, ErrorCodes } from "../error.js";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
export namespace Team {
export const Info = z
.object({
id: z.string().meta({
description: "The unique identifier of the team",
example: Examples.Team.id,
}),
email: z.email().meta({
description: "The contact email address for the team",
example: Examples.Team.email,
}),
polarCustomerID: z.string().nullable().meta({
description: "Associated Polar.sh customer identifier",
example: Examples.Team.polarCustomerID,
}),
})
.meta({
ref: "Team",
description: "Represents a team entity",
example: Examples.Team,
});
export type Info = z.infer<typeof Info>;
export class TeamExistsError extends VisibleError {
constructor(email: string) {
super(
"already_exists",
ErrorCodes.Validation.ALREADY_EXISTS,
`A team with this email ${email} already exists`,
);
}
}
export const create = fn(
Info.omit({ polarCustomerID: true }).partial({ id: true }),
async (input) => {
const userID = createID("team");
const polarCustomerID = await Polar.fromUserEmail(input.email).then(
(polarUser) => polarUser?.id || null,
);
const id = input.id ?? userID;
await Database.transaction((tx) => {
return tx
.insert(teamTable)
.values({
id,
email: input.email,
polarCustomerID,
})
.onConflictDoUpdate({
target: teamTable.email,
set: {
timeDeleted: null,
id: sql.raw(`excluded.${teamTable.id.name}`),
polarCustomerID: sql.raw(
`excluded.${teamTable.polarCustomerID.name}`,
),
},
});
});
return id;
},
);
export const fromEmail = fn(Info.shape.email, async (email) =>
Database.transaction(async (tx) =>
tx
.select()
.from(teamTable)
.where(and(eq(teamTable.email, email), isNull(teamTable.timeDeleted)))
.orderBy(asc(teamTable.timeCreated))
.execute()
.then((rows) => rows.map(serialize).at(0)),
),
);
export function serialize(
input: typeof teamTable.$inferSelect,
): z.infer<typeof Info> {
return {
id: input.id,
email: input.email,
polarCustomerID: input.polarCustomerID,
};
}
}

View File

@@ -0,0 +1,9 @@
import { id, timestamps } from "../drizzle/types.js";
import { pgTable, varchar } from "drizzle-orm/pg-core";
export const teamTable = pgTable("team", {
...id,
...timestamps,
email: varchar("email", { length: 255 }).unique().notNull(),
polarCustomerID: varchar("polar_customer_id", { length: 255 }),
});

View File

@@ -0,0 +1,30 @@
import { type ZodType, z } from "zod/v4";
export function fn<
Arg1 extends ZodType,
Callback extends (arg1: z.output<Arg1>) => any,
>(arg1: Arg1, cb: Callback) {
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
const parsed = arg1.parse(input);
return cb.apply(cb, [parsed as any]);
};
result.schema = arg1;
return result;
}
export function doubleFn<
Arg1 extends ZodType,
Arg2 extends ZodType,
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
const result = function (
input: z.input<typeof arg1>,
input2: z.input<typeof arg2>,
): ReturnType<Callback> {
const parsed = arg1.parse(input);
const parsed2 = arg2.parse(input2);
return cb.apply(cb, [parsed as any, parsed2 as any]);
};
result.schema = arg1;
return result;
}

View File

@@ -0,0 +1,23 @@
import { ulid } from "ulid";
export const prefixes = {
apiApp: "app",
apiSecret: "sec",
apiPersonal: "pat",
team: "team",
member: "mebr",
} as const;
/**
* Generates a unique identifier by concatenating a predefined prefix with a ULID.
*
* Given a key from the predefined prefixes mapping (e.g.,"team", "member", "steam"),
* this function retrieves the corresponding prefix and combines it with a ULID using an underscore
* as a separator. The resulting identifier is formatted as "prefix_ulid".
*
* @param prefix - A key from the prefixes mapping.
* @returns A unique identifier string.
*/
export function createID(prefix: keyof typeof prefixes): string {
return [prefixes[prefix], ulid()].join("_");
}

View File

@@ -0,0 +1,2 @@
export * from "./log.js";
export * from "./types.js";

View File

@@ -0,0 +1,76 @@
import { Context } from "../context.js";
export namespace Log {
const ctx = Context.create<{
tags: Record<string, any>;
}>();
export function create(tags?: Record<string, any>) {
tags = tags || {};
const result = {
info(msg: string, extra?: Record<string, any>) {
const prefix = Object.entries({
...use().tags,
...tags,
...extra,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ");
console.log(prefix, msg);
return result;
},
warn(msg: string, extra?: Record<string, any>) {
const prefix = Object.entries({
...use().tags,
...tags,
...extra,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ");
console.warn(prefix, msg);
return result;
},
error(error: Error) {
const prefix = Object.entries({
...use().tags,
...tags,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ");
console.error(prefix, error);
return result;
},
tag(key: string, value: string) {
tags[key] = value;
return result;
},
clone() {
return Log.create({ ...tags });
},
};
return result;
}
export function provide<R>(tags: Record<string, any>, cb: () => R) {
const existing = use();
return ctx.provide(
{
tags: {
...existing.tags,
...tags,
},
},
cb,
);
}
function use() {
try {
return ctx.use();
} catch (e) {
return { tags: {} };
}
}
}

View File

@@ -0,0 +1,16 @@
export type RequireKeys<T extends object, K extends keyof T> = Required<
Pick<T, K>
> &
Omit<T, K> extends infer O
? { [P in keyof O]: O[P] }
: never;
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;
export type IdempotencyKey = Branded<string, "IdempotencyKey">;

View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"jsx": "react",
"strict": true,
"alwaysStrict": true,
"strictPropertyInitialization": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"removeComments": false,
"esModuleInterop": true,
"emitDecoratorMetadata": false,
"experimentalDecorators": false,
"downlevelIteration": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"pretty": true
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.base.json",
"references": [
{
"path": "./tsconfig.src.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*.ts"],
"compilerOptions": {
"isolatedDeclarations": false,
"composite": true,
"sourceMap": true
}
}