mirror of
https://github.com/nestriness/nestri.git
synced 2026-08-02 01:35:18 +03:00
feat: Add a lot of stuff
This commit is contained in:
34
packages/core/.gitignore
vendored
Normal file
34
packages/core/.gitignore
vendored
Normal 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
15
packages/core/README.md
Normal 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.
|
||||
29
packages/core/package.json
Normal file
29
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
89
packages/core/src/actor.ts
Normal file
89
packages/core/src/actor.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
packages/core/src/api/api.sql.ts
Normal file
27
packages/core/src/api/api.sql.ts
Normal 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(),
|
||||
});
|
||||
31
packages/core/src/api/app.ts
Normal file
31
packages/core/src/api/app.ts
Normal 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>;
|
||||
}
|
||||
7
packages/core/src/api/index.ts
Normal file
7
packages/core/src/api/index.ts
Normal 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;
|
||||
}
|
||||
45
packages/core/src/api/personal.ts
Normal file
45
packages/core/src/api/personal.ts
Normal 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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
21
packages/core/src/context.ts
Normal file
21
packages/core/src/context.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
90
packages/core/src/drizzle/index.ts
Normal file
90
packages/core/src/drizzle/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/core/src/drizzle/types.ts
Normal file
39
packages/core/src/drizzle/types.ts
Normal 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
145
packages/core/src/error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
138
packages/core/src/event/event.ts
Normal file
138
packages/core/src/event/event.ts
Normal 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;
|
||||
}
|
||||
12
packages/core/src/event/index.ts
Normal file
12
packages/core/src/event/index.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
9
packages/core/src/event/validator.ts
Normal file
9
packages/core/src/event/validator.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
32
packages/core/src/examples.ts
Normal file
32
packages/core/src/examples.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
32
packages/core/src/member/index.ts
Normal file
32
packages/core/src/member/index.ts
Normal 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>;
|
||||
}
|
||||
15
packages/core/src/member/member.sql.ts
Normal file
15
packages/core/src/member/member.sql.ts
Normal 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(),
|
||||
});
|
||||
20
packages/core/src/polar/index.ts
Normal file
20
packages/core/src/polar/index.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
47
packages/core/src/redis/client.ts
Normal file
47
packages/core/src/redis/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
755
packages/core/src/redis/queue.ts
Normal file
755
packages/core/src/redis/queue.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
3
packages/core/src/session/index.ts
Normal file
3
packages/core/src/session/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./json.js";
|
||||
export * from "./types.js";
|
||||
export * from "./schema.js";
|
||||
118
packages/core/src/session/json.ts
Normal file
118
packages/core/src/session/json.ts
Normal 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";
|
||||
152
packages/core/src/session/schema.ts
Normal file
152
packages/core/src/session/schema.ts
Normal 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");
|
||||
}
|
||||
1076
packages/core/src/session/types.ts
Normal file
1076
packages/core/src/session/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
packages/core/src/team/index.ts
Normal file
100
packages/core/src/team/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
packages/core/src/team/team.sql.ts
Normal file
9
packages/core/src/team/team.sql.ts
Normal 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 }),
|
||||
});
|
||||
30
packages/core/src/utils/fn.ts
Normal file
30
packages/core/src/utils/fn.ts
Normal 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;
|
||||
}
|
||||
23
packages/core/src/utils/id.ts
Normal file
23
packages/core/src/utils/id.ts
Normal 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("_");
|
||||
}
|
||||
2
packages/core/src/utils/index.ts
Normal file
2
packages/core/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./log.js";
|
||||
export * from "./types.js";
|
||||
76
packages/core/src/utils/log.ts
Normal file
76
packages/core/src/utils/log.ts
Normal 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: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/core/src/utils/types.ts
Normal file
16
packages/core/src/utils/types.ts
Normal 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">;
|
||||
35
packages/core/tsconfig.base.json
Normal file
35
packages/core/tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
8
packages/core/tsconfig.json
Normal file
8
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.src.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
packages/core/tsconfig.src.json
Normal file
9
packages/core/tsconfig.src.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"composite": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user