Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"maple/no-ordie-compiled-query": "error",
"maple/no-react-use-effect": "warn",
"maple/no-record-string-any": "error",
"maple/no-try-catch": "error",
"typescript/no-explicit-any": "warn",
"typescript/no-non-null-assertion": "error",
"no-alert": "error",
"oxc/approx-constant": "warn",
"no-plusplus": "off",
Expand All @@ -54,6 +56,45 @@
"maple/no-ordie-compiled-query": "off"
}
},
// A test asserts on a fixture it just built, so `!` there documents the fixture
// rather than hiding an unchecked value; and a test that drives a throwing
// boundary on purpose needs `try`/`catch` to observe it.
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/test/**",
"**/tests/**",
"**/__tests__/**"
],
"rules": {
"maple/no-try-catch": "off",
"typescript/no-non-null-assertion": "off"
}
},
// The rule points at an Effect primitive, so it only applies where Effect is on
// the dependency list. These three ship to customers with no runtime deps at all
// (`@maple/browser` and `@maple/browser-session` are under a bundle-size budget),
// so `try`/`catch` is the only error handling they have.
{
"files": ["packages/browser/**", "packages/browser-session/**", "packages/clickhouse-cli/**"],
"rules": {
"maple/no-try-catch": "off"
}
},
// Burndown list, not a policy. `packages/*` and `lib/*` source is clean and
// gated for both rules; the apps and the one-shot ops scripts still hold
// ~460 `try`/`catch` blocks and ~500 non-null assertions between them.
// Drop entries from this list as each is converted rather than widening it.
{
"files": ["apps/**", "scripts/**", "**/scripts/**", "examples/**", "alchemy.run.ts"],
"rules": {
"maple/no-try-catch": "off",
"typescript/no-non-null-assertion": "off"
}
},
// Standalone generic DSLs use `any` as a variance placeholder; Maple product code may not.
{
"files": ["lib/**"],
Expand Down
4 changes: 2 additions & 2 deletions lib/cache/src/edge-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ const sha256Hex = async (input: string): Promise<string> => {
const digest = await crypto.subtle.digest("SHA-256", bytes)
const view = new Uint8Array(digest)
let out = ""
for (let i = 0; i < view.length; i++) {
out += view[i]!.toString(16).padStart(2, "0")
for (const byte of view) {
out += byte.toString(16).padStart(2, "0")
}
return out
}
Expand Down
8 changes: 5 additions & 3 deletions lib/clickhouse-builder/src/ch/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,9 @@ export interface RowSchemaMismatch {
const structFieldNames = (schema: unknown): ReadonlyArray<string> | undefined => {
const ast = (schema as { readonly ast?: { readonly _tag?: string } } | undefined)?.ast
if (ast?._tag !== "Objects") return undefined
const signatures = (ast as { readonly propertySignatures?: ReadonlyArray<{ readonly name: PropertyKey }> })
.propertySignatures
const signatures = (
ast as { readonly propertySignatures?: ReadonlyArray<{ readonly name: PropertyKey }> }
).propertySignatures
return signatures?.map((signature) => String(signature.name))
}

Expand Down Expand Up @@ -865,7 +866,8 @@ const deriveUnionRowSchema = (

const fields: Record<string, Schema.Codec<any, any>> = {}
for (const [alias, schemas] of perColumn) {
fields[alias] = schemas.length === 1 ? schemas[0]! : Schema.Union(schemas)
const only = schemas.length === 1 ? schemas[0] : undefined
fields[alias] = only ?? Schema.Union(schemas)
}
return { schema: Schema.Struct(fields) }
}
Expand Down
3 changes: 2 additions & 1 deletion lib/clickhouse-builder/src/ch/define-fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export const withoutNull = <T>(
const rest = ast.types.filter((type) => type._tag !== "Null")
if (rest.length === ast.types.length || rest.length === 0) return undefined
const members = rest.map((type) => Schema.make(type))
return (members.length === 1 ? members[0]! : Schema.Union(members)) as Schema.Codec<T, any>
const only = members.length === 1 ? members[0] : undefined
return (only ?? Schema.Union(members)) as Schema.Codec<T, any>
}

// Re-export for consumer convenience
Expand Down
12 changes: 7 additions & 5 deletions lib/effect-cloudflare/src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,16 @@ export const fromCloudflareSocket = (cfSocket: cf.Socket): Socket.Socket => {
latch.whenOpen(
Effect.suspend(() => {
if (Socket.isCloseEvent(chunk)) {
return Deferred.fail(currentFiberSet!.deferred, closeError(chunk.code, chunk.reason))
}
if (!writerRef) {
writerRef = cfSocket.writable.getWriter()
// `latch.whenOpen` only admits a write while a run is in flight, and
// a run is exactly what sets `currentFiberSet`.
const fiberSet = currentFiberSet
if (fiberSet === undefined) return Effect.void
return Deferred.fail(fiberSet.deferred, closeError(chunk.code, chunk.reason))
}
const writer = (writerRef ??= cfSocket.writable.getWriter())
const data = typeof chunk === "string" ? encoder.encode(chunk) : chunk
return Effect.tryPromise({
try: () => writerRef!.write(data),
try: () => writer.write(data),
catch: (cause) =>
new Socket.SocketError({
reason: new Socket.SocketWriteError({ cause }),
Expand Down
27 changes: 15 additions & 12 deletions lib/effect-cloudflare/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Context, Effect } from "effect"
import { Cause, ConfigProvider, Exit, Layer, ManagedRuntime } from "effect"
import type { Context } from "effect"
import { Cause, ConfigProvider, Effect, Exit, Layer, ManagedRuntime, Result } from "effect"

/**
* Minimal shape of CF `ExecutionContext.waitUntil`. Accept any structurally
Expand Down Expand Up @@ -49,10 +49,13 @@ export const buildRequestRuntime = <R>(
})
const flush = async () => {
await drainScheduler()
try {
await runtime.dispose()
} catch (err) {
console.error("[effect-cloudflare] runtime flush failed:", err)
// `flush` runs inside `ctx.waitUntil`, where a rejection surfaces as a
// Worker error caused purely by teardown.
const disposed = await Effect.runPromise(
Effect.result(Effect.tryPromise({ try: () => runtime.dispose(), catch: (cause) => cause })),
)
if (Result.isFailure(disposed)) {
console.error("[effect-cloudflare] runtime flush failed:", disposed.failure)
}
}
return { services, flush }
Expand Down Expand Up @@ -80,12 +83,12 @@ export const withRequestRuntime = <R, Env extends Record<string, unknown>, Ctx e
const response = handler(request, resolvedServices, env, ctx)
ctx.waitUntil(
(async () => {
try {
await response
} catch {
// Swallow handler errors — the handler's own error path is
// responsible for surfacing them.
}
// Wait for the response without adopting its rejection — the
// handler's own error path is responsible for surfacing that; here
// it only marks the point where the scope may close.
await Effect.runPromise(
Effect.ignore(Effect.tryPromise({ try: () => response, catch: (cause) => cause })),
)
await flush()
})(),
)
Expand Down
4 changes: 2 additions & 2 deletions lib/effect-db/src/atom/AtomTanStackDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const makeSingleCollectionAtom = <T extends object, TKey extends string |
}

const entries = Array.from(collection.entries())
const newData = entries.length > 0 ? entries[0]![1] : undefined
const newData = entries[0]?.[1]
get.setSelf(AsyncResult.success(newData))
})

Expand All @@ -149,7 +149,7 @@ export const makeSingleCollectionAtom = <T extends object, TKey extends string |
}

const entries = Array.from(collection.entries())
const initialData = entries.length > 0 ? entries[0]![1] : undefined
const initialData = entries[0]?.[1]

return AsyncResult.success(initialData)
})
Expand Down
26 changes: 16 additions & 10 deletions lib/effect-db/src/electric/optimistic-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,23 @@ export function optimisticAction<
mutateResult = onMutate(variables)
})

yield* Effect.tryPromise({
try: () => transaction.isPersisted.promise,
catch: (error) => {
// Roll back before mapping, as its own step in the chain: the rollback is
// best effort, and a rollback that throws must not displace the mutation
// failure the caller is owed.
const rollbackIfPending = Effect.ignore(
Effect.try(() => {
if (transaction.state !== "completed" && transaction.state !== "failed") {
try {
transaction.rollback()
} catch {
// Best effort; preserve the mutation failure below.
}
transaction.rollback()
}
}),
)

yield* Effect.tryPromise({
try: () => transaction.isPersisted.promise,
catch: (error) => error,
}).pipe(
Effect.tapError(() => rollbackIfPending),
Effect.mapError((error) => {
if (error && typeof error === "object" && "_tag" in error) {
return error as TError | SyncError
}
Expand All @@ -272,8 +278,8 @@ export function optimisticAction<
message: error instanceof Error ? error.message : "Optimistic action failed",
cause: error,
})
},
})
}),
)

return {
data: mutationResult.data,
Expand Down
8 changes: 3 additions & 5 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,8 @@ export const makeResolveTenant = (
? verifyOrgMembership
: undefined

if (!auth.orgId && !orgIdOverride) {
const sessionOrgId = orgIdOverride ?? auth.orgId
if (!sessionOrgId) {
// No active organization in the session. A request that names one it
// can prove membership of is still serviceable — this is the widget
// publishing path, whose whole point is not to disturb whatever the
Expand All @@ -705,10 +706,7 @@ export const makeResolveTenant = (
}

const clerkTenant: TenantContext = {
orgId: yield* decodeOrgId(
orgIdOverride ?? auth.orgId!,
"Invalid organization in Clerk session token",
),
orgId: yield* decodeOrgId(sessionOrgId, "Invalid organization in Clerk session token"),
userId,
roles:
typeof auth.orgRole === "string"
Expand Down
2 changes: 1 addition & 1 deletion packages/clickhouse-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ interface Flags {
function parseFlags(args: ReadonlyArray<string>): Flags {
const flags: Record<string, string> = {}
for (let i = 0; i < args.length; i++) {
const a = args[i]!
const a = args[i] ?? ""
if (!a.startsWith("--")) {
continue
}
Expand Down
13 changes: 5 additions & 8 deletions packages/domain/src/chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* *model* stream, while this is a *session* stream that also carries user turns, approval gates and
* turn lifecycle. `apps/api/src/chat/events.ts` is the only place the two are mapped.
*/
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { ActorId, AuthMode, OrgId, RoleName, UserId } from "./primitives"

// Session addressing
Expand Down Expand Up @@ -337,13 +337,10 @@ export const decodeChatEventOrThrow = Schema.decodeUnknownSync(Schema.fromJsonSt
* unrecognised frame instead means adding a new `ChatEvent` member degrades old clients rather than
* bricking them.
*/
export const decodeChatEvent = (frame: string): ChatEvent | undefined => {
try {
return decodeChatEventOrThrow(frame)
} catch {
return undefined
}
}
const decodeChatEventOption = Schema.decodeUnknownOption(Schema.fromJsonString(ChatEvent))

export const decodeChatEvent = (frame: string): ChatEvent | undefined =>
Option.getOrUndefined(decodeChatEventOption(frame))

/**
* Durable-storage codec for the event log.
Expand Down
15 changes: 10 additions & 5 deletions packages/domain/src/clickhouse/apply-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* step's `sql` in order; the Workflow additionally wraps each in a durable
* `step.do(step.name, …)` for resumability + progress.
*/
import { Option, Schema } from "effect"

import {
compileBackfillChunk,
isBackfill,
Expand Down Expand Up @@ -55,15 +57,18 @@ const toChDateTime = (unixSeconds: number): string => {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`
}

const decodeJsonRow = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
)

const parseFirstRow = (text: string): Record<string, unknown> | null => {
for (const line of text.split("\n")) {
const trimmed = line.trim()
if (trimmed.length === 0) continue
try {
return JSON.parse(trimmed) as Record<string, unknown>
} catch {
// ignore — controlled query
}
// The query is ours and asks for JSONEachRow, so a line that does not decode
// is a warning or a blank the server interleaved — skip to the next one.
const row = decodeJsonRow(trimmed)
if (Option.isSome(row)) return row.value
}
return null
}
Expand Down
14 changes: 9 additions & 5 deletions packages/domain/src/clickhouse/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,15 @@ const parseVersion = (version: string): readonly [number, number, number] => {
}

export const clickHouseVersionAtLeast = (actual: string, minimum: string): boolean => {
const left = parseVersion(actual)
const right = parseVersion(minimum)
for (let i = 0; i < 3; i++) {
if (left[i]! > right[i]!) return true
if (left[i]! < right[i]!) return false
const [leftMajor, leftMinor, leftPatch] = parseVersion(actual)
const [rightMajor, rightMinor, rightPatch] = parseVersion(minimum)
for (const [left, right] of [
[leftMajor, rightMajor],
[leftMinor, rightMinor],
[leftPatch, rightPatch],
] as const) {
if (left > right) return true
if (left < right) return false
}
return true
}
Expand Down
6 changes: 3 additions & 3 deletions packages/domain/src/http/v2/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,13 @@ const isReadOnlyPost = (path: string): boolean =>
* read access; mutation methods require write access. Returns null for non-/v2 paths.
*/
export const requiredScopeForRequest = (method: string, path: string): RequiredScope | null => {
const match = /^\/v2\/([a-z][a-z0-9_]*)(?:\/|$)/.exec(path)
if (match === null) return null
const [, family] = /^\/v2\/([a-z][a-z0-9_]*)(?:\/|$)/.exec(path) ?? []
if (family === undefined) return null
const access =
method === "GET" || method === "HEAD" || (method === "POST" && isReadOnlyPost(path))
? "read"
: "write"
return { family: match[1]!, access }
return { family, access }
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/domain/src/http/v2/envelopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ export const ListOf = <S extends Schema.Top>(item: S) =>
export const encodeOffsetCursor = (offset: number): string => `off_${offset.toString(36)}`

export const decodeOffsetCursor = (cursor: string): number | null => {
const match = /^off_([0-9a-z]+)$/.exec(cursor)
if (match === null) return null
const offset = Number.parseInt(match[1]!, 36)
const [, digits] = /^off_([0-9a-z]+)$/.exec(cursor) ?? []
if (digits === undefined) return null
const offset = Number.parseInt(digits, 36)
return Number.isSafeInteger(offset) && offset >= 0 ? offset : null
}

Expand Down
Loading