Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Polyglot persistence is the idea that one product rarely has one ideal database.
- posts PK is hash-sharded, then the leftover unique on `posts(id)` from `ALTER PRIMARY KEY` is dropped so inserts do not keep appending to an unhashed unique
- Secondary-index hotspots: a global `created_at` index is sequential even when the PK is hashed, so that index is the one that gets hashed

- Labeled property graph: nodes carry labels and properties; relationships are first-class directed typed edges (`FOLLOWS`, `AUTHORED`)
- Cypher pattern matching: `MATCH (u)-[:FOLLOWS]->(a)-[:AUTHORED]->(p)` is the 2-hop feed, not a join table plus a posts scan
- Variable-length paths and `shortestPath((a)-[:FOLLOWS*..16]->(b))` (BFS with a hop cap). A recursive CTE or N+1 neighbor lookup in the other stores
- Friend-of-friend recommendation: 2-hop `FOLLOWS` expansion, exclude self and existing edges, rank by independent path count
- Common neighbors and 2-cycles (`(a)-[:FOLLOWS]->(b)-[:FOLLOWS]->(a)`) as mutual follows
- Graph uniqueness: `CREATE CONSTRAINT ... REQUIRE n.prop IS UNIQUE` (`Neo.ClientError.Schema.ConstraintValidationFailed`). `MERGE` on `FOLLOWS` is idempotent; `CREATE` on `User`/`Post` fails the unique constraint
## What's implemented

- Project scaffold with TypeScript strict mode, Vitest, and CI
Expand Down Expand Up @@ -86,6 +92,14 @@ Polyglot persistence is the idea that one product rarely has one ideal database.
- posts PK is hash-sharded, then the leftover unique on `posts(id)` from `ALTER PRIMARY KEY` is dropped so inserts do not keep appending to an unhashed unique
- Secondary-index hotspots: a global `created_at` index is sequential even when the PK is hashed, so that index is the one that gets hashed
- Distributed SQL backend (CockroachDB): same SQL, hash-sharded posts PK, ordered prefix scans, SERIALIZABLE retry
- Labeled property graph: nodes carry labels and properties; relationships are first-class directed typed edges (`FOLLOWS`, `AUTHORED`)
- Cypher pattern matching: `MATCH (u)-[:FOLLOWS]->(a)-[:AUTHORED]->(p)` is the 2-hop feed, not a join table plus a posts scan
- Variable-length paths and `shortestPath((a)-[:FOLLOWS*..16]->(b))` (BFS with a hop cap). A recursive CTE or N+1 neighbor lookup in the other stores
- Friend-of-friend recommendation: 2-hop `FOLLOWS` expansion, exclude self and existing edges, rank by independent path count
- Common neighbors and 2-cycles (`(a)-[:FOLLOWS]->(b)-[:FOLLOWS]->(a)`) as mutual follows
- Graph uniqueness: `CREATE CONSTRAINT ... REQUIRE n.prop IS UNIQUE` (`Neo.ClientError.Schema.ConstraintValidationFailed`). `MERGE` on `FOLLOWS` is idempotent; `CREATE` on `User`/`Post` fails the unique constraint
- Graph backend (Neo4j): model the relationships, Cypher queries the others struggle with
- Graph backend (Neo4j-style `MemoryGraph`): model the relationships, plus the Cypher catalog for the walks the others struggle with
## Usage

```ts
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ export {
SERIALIZATION_FAILURE,
withSerializableRetry,
} from './cockroach/store'

export { CONSTRAINTS, CYPHER, MemoryGraph, Neo4jStore } from './neo4j/store'
32 changes: 32 additions & 0 deletions src/neo4j/cypher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const CONSTRAINTS = [
'CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE',
'CREATE CONSTRAINT user_handle IF NOT EXISTS FOR (u:User) REQUIRE u.handle IS UNIQUE',
'CREATE CONSTRAINT post_id IF NOT EXISTS FOR (p:Post) REQUIRE p.id IS UNIQUE',
] as const

const USER = 'u.id AS id, u.handle AS handle, u.createdAt AS createdAt'
const POST = 'p.id AS id, a.id AS authorId, p.body AS body, p.createdAt AS createdAt'
const NEWEST = 'ORDER BY p.createdAt DESC, p.id DESC LIMIT $limit'
const BEFORE =
'WHERE p.createdAt < $createdAt OR (p.createdAt = $createdAt AND p.id < $postId)'

export const CYPHER = {
createUser: `CREATE (u:User {id: $id, handle: $handle, createdAt: $createdAt}) RETURN ${USER}`,
getUser: `MATCH (u:User {id: $id}) RETURN ${USER}`,
getUserByHandle: `MATCH (u:User {handle: $handle}) RETURN ${USER}`,
follow: `MATCH (a:User {id: $from}), (b:User {id: $to}) WHERE a <> b MERGE (a)-[:FOLLOWS]->(b)`,
unfollow: `MATCH (a:User {id: $from})-[r:FOLLOWS]->(b:User {id: $to}) DELETE r`,
isFollowing: `MATCH (a:User {id: $from})-[r:FOLLOWS]->(b:User {id: $to}) RETURN count(r) AS n`,
following: `MATCH (a:User {id: $id})-[:FOLLOWS]->(b:User) RETURN b.id AS id ORDER BY b.id`,
followers: `MATCH (a:User {id: $id})<-[:FOLLOWS]-(b:User) RETURN b.id AS id ORDER BY b.id`,
publish: `MATCH (a:User {id: $authorId}) CREATE (p:Post {id: $id, body: $body, createdAt: $createdAt}) CREATE (a)-[:AUTHORED]->(p) RETURN ${POST}`,
getPost: `MATCH (a:User)-[:AUTHORED]->(p:Post {id: $id}) RETURN ${POST}`,
authorTimeline: `MATCH (a:User {id: $authorId})-[:AUTHORED]->(p:Post) RETURN ${POST} ${NEWEST}`,
authorTimelineBefore: `MATCH (a:User {id: $authorId})-[:AUTHORED]->(p:Post) ${BEFORE} RETURN ${POST} ${NEWEST}`,
feed: `MATCH (u:User {id: $userId})-[:FOLLOWS]->(a:User)-[:AUTHORED]->(p:Post) RETURN ${POST} ${NEWEST}`,
feedBefore: `MATCH (u:User {id: $userId})-[:FOLLOWS]->(a:User)-[:AUTHORED]->(p:Post) ${BEFORE} RETURN ${POST} ${NEWEST}`,
shortestPath: `MATCH (a:User {id: $from}), (b:User {id: $to}) MATCH path = shortestPath((a)-[:FOLLOWS*..16]->(b)) RETURN [n IN nodes(path) | n.id] AS ids`,
recommend: `MATCH (u:User {id: $id})-[:FOLLOWS]->()-[:FOLLOWS]->(rec:User) WHERE rec.id <> $id AND NOT (u)-[:FOLLOWS]->(rec) RETURN rec.id AS id, count(*) AS score ORDER BY score DESC, rec.id LIMIT $limit`,
isMutual: `MATCH (a:User {id: $a})-[:FOLLOWS]->(b:User {id: $b})-[:FOLLOWS]->(a) RETURN count(*) > 0 AS mutual`,
commonFollowees: `MATCH (a:User {id: $a})-[:FOLLOWS]->(x:User)<-[:FOLLOWS]-(b:User {id: $b}) RETURN x.id AS id ORDER BY x.id`,
} as const
141 changes: 141 additions & 0 deletions src/neo4j/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
export class ConstraintError extends Error {
readonly code = 'Neo.ClientError.Schema.ConstraintValidationFailed'
constructor(readonly constraint: string) {
super(constraint)
this.name = 'Neo4jError'
}
}

export type Props = Record<string, unknown>

interface Node {
labels: string[]
props: Props
}

function specOf(cypher: string): { name: string; label: string; prop: string } | undefined {
const m = /CONSTRAINT\s+(\w+)\s+.*FOR\s+\(\w+:(\w+)\)\s+REQUIRE\s+\w+\.(\w+)/.exec(cypher)
if (!m?.[1] || !m[2] || !m[3]) return undefined
return { name: m[1], label: m[2], prop: m[3] }
}

interface UniqueIndex {
label: string
prop: string
seen: Map<string, string>
}

export class MemoryGraph {
private readonly nodes = new Map<string, Node>()
private readonly out = new Map<string, { type: string; to: string }[]>()
private readonly unique = new Map<string, UniqueIndex>()
private seq = 0

constrain(cypher: string): void {
const spec = specOf(cypher)
if (spec && !this.unique.has(spec.name)) {
this.unique.set(spec.name, { label: spec.label, prop: spec.prop, seen: new Map() })
}
}

nodesWithLabel(label: string): Node[] {
return [...this.nodes.values()].filter((n) => n.labels.includes(label))
}

rels(type: string): { type: string; fromId: unknown; toId: unknown }[] {
const rows: { type: string; fromId: unknown; toId: unknown }[] = []
for (const [from, list] of this.out) {
for (const rel of list) {
if (rel.type === type) {
rows.push({
type,
fromId: this.nodes.get(from)?.props.id,
toId: this.nodes.get(rel.to)?.props.id,
})
}
}
}
return rows
}

createNode(labels: string[], props: Props): string {
for (const [name, index] of this.unique) {
if (!labels.includes(index.label) || props[index.prop] === undefined) continue
if (index.seen.has(String(props[index.prop]))) throw new ConstraintError(name)
}
const id = `n${this.seq++}`
this.nodes.set(id, { labels, props: { ...props } })
this.out.set(id, [])
for (const index of this.unique.values()) {
if (labels.includes(index.label) && props[index.prop] !== undefined) {
index.seen.set(String(props[index.prop]), id)
}
}
return id
}

find(label: string, prop: string, value: unknown): string | undefined {
for (const [id, node] of this.nodes) {
if (node.labels.includes(label) && node.props[prop] === value) return id
}
return undefined
}

props(id: string | undefined): Props | undefined {
return id === undefined ? undefined : this.nodes.get(id)?.props
}

mergeRel(from: string, type: string, to: string): void {
if (type === 'FOLLOWS' && from === to) throw new Error('follows_no_self')
const list = this.out.get(from)
if (!list || list.some((r) => r.type === type && r.to === to)) return
list.push({ type, to })
}

deleteRel(from: string, type: string, to: string): void {
const list = this.out.get(from)
if (list) this.out.set(from, list.filter((r) => !(r.type === type && r.to === to)))
}

neighbors(from: string, type: string): string[] {
return (this.out.get(from) ?? []).filter((r) => r.type === type).map((r) => r.to)
}

inbound(to: string, type: string): string[] {
const ids: string[] = []
for (const [from, list] of this.out) {
if (list.some((r) => r.type === type && r.to === to)) ids.push(from)
}
return ids
}

bfs(start: string, goal: string, type: string, maxHops: number): string[] | null {
if (start === goal) return null
const parent = new Map<string, string>()
const dist = new Map([[start, 0]])
const queue = [start]
for (let i = 0; i < queue.length; i++) {
const cur = queue[i]
if (cur === undefined) break
const d = dist.get(cur) ?? 0
if (d >= maxHops) continue
for (const next of this.neighbors(cur, type)) {
if (dist.has(next)) continue
dist.set(next, d + 1)
parent.set(next, cur)
if (next === goal) {
const ids: string[] = []
let at: string | undefined = goal
while (at) {
ids.push(String(this.nodes.get(at)?.props.id ?? ''))
if (at === start) break
at = parent.get(at)
}
return ids.reverse()
}
queue.push(next)
}
}
return null
}
}
Loading
Loading