diff --git a/apps/roam/src/components/settings/AdminPanel.tsx b/apps/roam/src/components/settings/AdminPanel.tsx index b21c78bd9..96ef9506a 100644 --- a/apps/roam/src/components/settings/AdminPanel.tsx +++ b/apps/roam/src/components/settings/AdminPanel.tsx @@ -7,15 +7,15 @@ import { SupabaseContext, } from "~/utils/supabaseContext"; import { - getNodes, - getNodeSchemas, + getConcepts, + getSchemaConcepts, nodeSchemaSignature, type NodeSignature, - type PConcept, + type PConceptFull, } from "@repo/database/lib/queries"; import { DGSupabaseClient } from "@repo/database/lib/client"; -const NodeRow = ({ node }: { node: PConcept }) => { +const NodeRow = ({ node }: { node: PConceptFull }) => { return ( {node.name} @@ -69,7 +69,7 @@ const NodeRow = ({ node }: { node: PConcept }) => { ); }; -const NodeTable = ({ nodes }: { nodes: PConcept[] }) => { +const NodeTable = ({ nodes }: { nodes: PConceptFull[] }) => { return ( @@ -83,7 +83,7 @@ const NodeTable = ({ nodes }: { nodes: PConcept[] }) => { - {nodes.map((node: PConcept) => ( + {nodes.map((node: PConceptFull) => ( ))} @@ -97,7 +97,7 @@ const AdminPanel = () => { const [schemas, setSchemas] = useState([]); const [showingSchema, setShowingSchema] = useState(nodeSchemaSignature); - const [nodes, setNodes] = useState([]); + const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [loadingNodes, setLoadingNodes] = useState(true); const [error, setError] = useState(null); @@ -130,10 +130,10 @@ const AdminPanel = () => { void (async () => { if (!ignore && supabase !== null && context !== null) { try { - setSchemas(await getNodeSchemas(supabase, context.spaceId)); + setSchemas(await getSchemaConcepts(supabase, context.spaceId)); } catch (e) { setError((e as Error).message); - console.error("getNodeSchemas failed", e); + console.error("getSchemaConcepts failed", e); } finally { setLoading(false); } @@ -157,7 +157,7 @@ const AdminPanel = () => { try { setLoadingNodes(true); setNodes( - await getNodes({ + await getConcepts({ supabase, spaceId, schemaLocalIds: showingSchema.sourceLocalId, @@ -165,7 +165,7 @@ const AdminPanel = () => { ); } catch (e) { setError((e as Error).message); - console.error("getNodes failed", e); + console.error("getConcepts failed", e); } finally { setLoadingNodes(false); } diff --git a/packages/database/README.md b/packages/database/README.md index 1fc148116..41bfc1030 100644 --- a/packages/database/README.md +++ b/packages/database/README.md @@ -71,10 +71,12 @@ If schema changes are deployed to `main` by another developer while you work on There are [cucumber](https://cucumber.io/) scenarios (in `packages/database/features`) to test the flow of database operations. We have not yet automated those tests, but you should run against the local environment when developing the database. You will need to: +1. set `SUPABASE_DB=local` in `packages/database/.env` 1. Run `turbo dev` in one terminal (in the root directory) -2. In another other terminal, `cd` to this directory (`packages/database`) and run the tests with `pnpm run test` +1. In another other terminal, `cd` to this directory (`packages/database`) and run the tests with `pnpm run test` Think of adding new tests if appropriate! +Some more details in `doc/tests.md` ## Using local code against your Supabase branch diff --git a/packages/database/doc/tests.md b/packages/database/doc/tests.md new file mode 100644 index 000000000..4553d4830 --- /dev/null +++ b/packages/database/doc/tests.md @@ -0,0 +1,33 @@ +# Designing cucumber tests + +Cucumber is a harness for the gherkin language, allowing to make tests more legible. The steps are defined as regexp in `features/step-definitions/stepdefs.ts`. Currently, we assume the database is running with `turbo dev` in another terminal. + +Some of test steps were defined to clear the database (`Given the database is blank`) or to put arbitrary data in the tables (`{word} are added to the database:`) which expects the name of a table as argument, and a markdown table for the table data. + +The latter step requires some further explanations: + +A lot of database objects use foreign keys, so we need to refer to numeric database identifiers. Those are defined by the database. To allow this to work, we have a pseudo-column called `$id`, which is a string alias that corresponds to the database numeric `id`. Make sure each value in that column is unique. We keep a dictionary of those aliases to the database numeric `id` in cucumber. When interpreting the table, if any other column is prefixed by a `_`, we will recursively search for strings and from the alias set and replace them with the appropriate database ids. Note that inserts are made in bulk, so you may need to break up your inserts according to dependencies. For example: + +- Adding a schema first + `And Concept are added to the database:` + + | $id | name | @is_schema | + | alias1 | Claim | true | + +- Then a concept referring to the schema + `And Concept are added to the database:` + | $id | name | @is_schema | \_schema_id | + | alias2 | claim 1 | false | alias1 | + +Also, cucumber treats all columns as strings; if they contain a non-string literal (essentially number, boolean or JSON) you can use the `@` prefix in the column name so the cell value will be parsed as json before sending to the database. (`@` comes before `_` if both are used.) + +Other steps that require explanation: + +- `a user logged in space {word} and calling getConcepts with these parameters: {string}` +- `Then query results should look like this` + +This comes in pairs: The results from the query (whose parameters are defined as json) are checked against a table, using the same syntax as above. Only the columns defined are checked for equivalence. + +- `the user {word} opens the {word} plugin in space {word}`. + +This both creates the space and an account tied to that space. Because tying spaces and accounts goes through an edge function, it is the only good way to do both. diff --git a/packages/database/features/addConcepts.feature b/packages/database/features/addConcepts.feature index 173f3c886..8e07c1fc3 100644 --- a/packages/database/features/addConcepts.feature +++ b/packages/database/features/addConcepts.feature @@ -14,7 +14,14 @@ Feature: Concept upsert Scenario Outline: Calling the upsert steps separately When user user1 upserts these documents to space s1: """json - [{ "source_local_id": "page1_uid", "created": "2000/01/01", "last_modified": "2001/01/02", "author_local_id":"user1"}] + [ + { + "source_local_id": "page1_uid", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "author_local_id": "user1" + } + ] """ And user user1 upserts this content to space s1: """json @@ -36,11 +43,80 @@ Feature: Concept upsert "last_modified": "2001/01/02", "text": "Some text" }, - { "author_local_id": "user2", "document_local_id":"page1_uid", "source_local_id": "s2", "scale":"document", "created": "2000/01/02", "last_modified": "2001/01/03", "part_of_local_id":"s1", "text": "Some subtext" }, { - "author_local_id": "user2", "document_inline": { "source_local_id": "page1_uid", "created": "2000/01/01", "last_modified": "2001/01/02", "author_local_id":"user2"}, "source_local_id": "s3", "scale": "document", "created": "2000/01/02", "last_modified": "2001/01/03", "part_of_local_id": "s2", "text": "Some subsubtext", "embedding_inline": { - "model":"openai_text_embedding_3_small_1536", "vector":[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - } } + "author_local_id": "user2", + "document_local_id": "page1_uid", + "source_local_id": "s2", + "scale": "document", + "created": "2000/01/02", + "last_modified": "2001/01/03", + "part_of_local_id": "s1", + "text": "Some subtext" + }, + { + "author_local_id": "user2", + "document_inline": { + "source_local_id": "page1_uid", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "author_local_id": "user2" + }, + "source_local_id": "s3", + "scale": "document", + "created": "2000/01/02", + "last_modified": "2001/01/03", + "part_of_local_id": "s2", + "text": "Some subsubtext", + "embedding_inline": { + "model": "openai_text_embedding_3_small_1536", + "vector": [ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + } + } ] """ And user user1 upserts these concepts to space s1: diff --git a/packages/database/features/addContent.feature b/packages/database/features/addContent.feature index b2a647824..19c025349 100644 --- a/packages/database/features/addContent.feature +++ b/packages/database/features/addContent.feature @@ -14,7 +14,14 @@ Feature: Content access Scenario Outline: Calling the upsert steps separately When user user1 upserts these documents to space s1: """json - [{ "source_local_id": "page1_uid", "created": "2000/01/01", "last_modified": "2001/01/02", "author_local_id":"user1"}] + [ + { + "source_local_id": "page1_uid", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "author_local_id": "user1" + } + ] """ And user user1 upserts this content to space s1: """json @@ -37,11 +44,83 @@ Feature: Content access "last_modified": "2001/01/02", "text": "Some text" }, - { "author_local_id": "user2", "document_local_id":"page1_uid", "space_local_id": "s1", "source_local_id": "s2", "scale":"document", "created": "2000/01/02", "last_modified": "2001/01/03", "part_of_local_id":"s1", "text": "Some subtext" }, { - "space_local_id": "s1", "author_local_id": "user2", "document_inline": { "source_local_id": "page1_uid", "space_local_id": "s1", "created": "2000/01/01", "last_modified": "2001/01/02", "author_local_id":"user2"}, "source_local_id": "s3", "scale": "document", "created": "2000/01/02", "last_modified": "2001/01/03", "part_of_local_id": "s2", "text": "Some subsubtext", "embedding_inline": { - "model":"openai_text_embedding_3_small_1536", "vector":[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - } } + "author_local_id": "user2", + "document_local_id": "page1_uid", + "space_local_id": "s1", + "source_local_id": "s2", + "scale": "document", + "created": "2000/01/02", + "last_modified": "2001/01/03", + "part_of_local_id": "s1", + "text": "Some subtext" + }, + { + "space_local_id": "s1", + "author_local_id": "user2", + "document_inline": { + "source_local_id": "page1_uid", + "space_local_id": "s1", + "created": "2000/01/01", + "last_modified": "2001/01/02", + "author_local_id": "user2" + }, + "source_local_id": "s3", + "scale": "document", + "created": "2000/01/02", + "last_modified": "2001/01/03", + "part_of_local_id": "s2", + "text": "Some subsubtext", + "embedding_inline": { + "model": "openai_text_embedding_3_small_1536", + "vector": [ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + } + } ] """ Then a user logged in space s1 should see 3 PlatformAccount in the database diff --git a/packages/database/features/contentAccess.feature b/packages/database/features/contentAccess.feature index 9539a09ab..540fb3dce 100644 --- a/packages/database/features/contentAccess.feature +++ b/packages/database/features/contentAccess.feature @@ -17,10 +17,10 @@ Feature: Content access And the user user3 opens the Roam plugin in space s1 And the user user3 opens the Roam plugin in space s2 And Document are added to the database: - | @id | _space_id | source_local_id | _author_id | created | last_modified | - | d1 | s1 | abc | user1 | 2025/01/01 | 2025/01/01 | - | d2 | s1 | def | user2 | 2025/01/01 | 2025/01/01 | - | d3 | s2 | ghi | user3 | 2025/01/01 | 2025/01/01 | + | $id | _space_id | source_local_id | _author_id | created | last_modified | + | d1 | s1 | abc | user1 | 2025/01/01 | 2025/01/01 | + | d2 | s1 | def | user2 | 2025/01/01 | 2025/01/01 | + | d3 | s2 | ghi | user3 | 2025/01/01 | 2025/01/01 | Scenario Outline: Per-space document access When the user user1 opens the Roam plugin in space s1 diff --git a/packages/database/features/queryConcepts.feature b/packages/database/features/queryConcepts.feature new file mode 100644 index 000000000..15e3b5ffa --- /dev/null +++ b/packages/database/features/queryConcepts.feature @@ -0,0 +1,114 @@ +Feature: Concept queries + User story: + * As a user of the Roam plugin + * Logged in through a given space's anonymous account + * With existing concepts + * I want to make various concept queries + + Acceptance criteria: + * The queries should succeed + + Background: + Given the database is blank + And the user user1 opens the Roam plugin in space s1 + And the user user2 opens the Roam plugin in space s1 + And the user user3 opens the Roam plugin in space s1 + # Add Documents as support for the Content objects + # Note: table syntax is explained in features/step-definitions/stepdefs.ts, look for `added to the database`. + And Document are added to the database: + | $id | source_local_id | created | last_modified | _author_id | _space_id | + | d1 | ld1 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d2 | ld2 | 2025/01/01 | 2025/01/01 | user1 | s1 | + | d5 | ld5 | 2025/01/01 | 2025/01/01 | user2 | s1 | + | d7 | ld7 | 2025/01/01 | 2025/01/01 | user1 | s1 | + # Add Content as support for the Concept objects, esp. schemas + And Content are added to the database: + | $id | source_local_id | _document_id | text | created | last_modified | scale | _author_id | _space_id | + | ct1 | lct1 | d1 | Claim | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct2 | lct2 | d2 | claim 1 | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + | ct5 | lct5 | d5 | Opposes | 2025/01/01 | 2025/01/01 | document | user2 | s1 | + | ct7 | lct7 | d7 | Hypothesis | 2025/01/01 | 2025/01/01 | document | user1 | s1 | + # First add schemas + And Concept are added to the database: + | $id | name | _space_id | _author_id | _represented_by_id | created | last_modified | @is_schema | _schema_id | @literal_content | @reference_content | + | c1 | Claim | s1 | user1 | ct1 | 2025/01/01 | 2025/01/01 | true | | {} | {} | + | c5 | Opposes | s1 | user1 | ct5 | 2025/01/01 | 2025/01/01 | true | | {"roles": ["target", "source"]} | {} | + | c7 | Hypothesis | s1 | user1 | ct7 | 2025/01/01 | 2025/01/01 | true | | {} | {} | + # Then nodes referring to the schemas + And Concept are added to the database: + | $id | name | _space_id | _author_id | created | last_modified | @is_schema | _schema_id | @literal_content | @reference_content | _represented_by_id | + | c2 | claim 1 | s1 | user1 | 2025/01/01 | 2025/01/01 | false | c1 | {} | {} | ct2 | + | c3 | claim 2 | s1 | user2 | 2025/01/01 | 2025/01/01 | false | c1 | {} | {} | | + | c4 | claim 3 | s1 | user3 | 2025/01/01 | 2025/01/01 | false | c1 | {} | {} | | + | c8 | hypothesis 1 | s1 | user3 | 2025/01/01 | 2025/01/01 | false | c7 | {} | {} | | + # Then relations (which refer to nodes) + And Concept are added to the database: + | $id | name | _space_id | _author_id | created | last_modified | @is_schema | _schema_id | @literal_content | @_reference_content | + | c6 | opposes 1 | s1 | user2 | 2025/01/01 | 2025/01/01 | false | c5 | {} | {"target": "c3", "source": "c2"} | + | c9 | opposes 2 | s1 | user2 | 2025/01/01 | 2025/01/01 | false | c5 | {} | {"target": "c8", "source": "c2"} | + + Scenario Outline: Query all nodes + And a user logged in space s1 and calling getConcepts with these parameters: '{"schemaLocalIds":[],"fetchNodes":null}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @_reference_content | + | c2 | claim 1 | s1 | user1 | false | c1 | {} | + | c3 | claim 2 | s1 | user2 | false | c1 | {} | + | c4 | claim 3 | s1 | user3 | false | c1 | {} | + | c6 | opposes 1 | s1 | user2 | false | c5 | {"target": "c3", "source": "c2"} | + | c8 | hypothesis 1 | s1 | user3 | false | c7 | {} | + | c9 | opposes 2 | s1 | user2 | false | c5 | {"target": "c8", "source": "c2"} | + + Scenario Outline: Query node schemas + And a user logged in space s1 and calling getConcepts with these parameters: '{"fetchNodes":null}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @reference_content | _represented_by_id | + | c1 | Claim | s1 | user1 | true | | {} | {} | ct1 | + | c5 | Opposes | s1 | user1 | true | | {"roles": ["target", "source"]} | {} | ct5 | + | c7 | Hypothesis | s1 | user1 | true | | {} | {} | ct7 | + + Scenario Outline: Query by node types + And a user logged in space s1 and calling getConcepts with these parameters: '{"schemaLocalIds":["lct1"]}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @reference_content | + | c2 | claim 1 | s1 | user1 | false | c1 | {} | {} | + | c3 | claim 2 | s1 | user2 | false | c1 | {} | {} | + | c4 | claim 3 | s1 | user3 | false | c1 | {} | {} | + + Scenario Outline: Query by author + And a user logged in space s1 and calling getConcepts with these parameters: '{"nodeAuthor":"user2","schemaLocalIds":[],"fetchNodes":null}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @_reference_content | + | c3 | claim 2 | s1 | user2 | false | c1 | {} | {} | + | c6 | opposes 1 | s1 | user2 | false | c5 | {} | {"target": "c3", "source": "c2"} | + | c9 | opposes 2 | s1 | user2 | false | c5 | {} | {"target": "c8", "source": "c2"} | + + Scenario Outline: Query by relation type + And a user logged in space s1 and calling getConcepts with these parameters: '{"inRelsOfTypeLocal":["lct5"],"schemaLocalIds":[]}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @_reference_content | + | c2 | claim 1 | s1 | user1 | false | c1 | {} | {} | + | c3 | claim 2 | s1 | user2 | false | c1 | {} | {} | + | c8 | hypothesis 1 | s1 | user3 | false | c7 | {} | {} | + + Scenario Outline: Query by related node type + And a user logged in space s1 and calling getConcepts with these parameters: '{"inRelsToNodesOfTypeLocal":["lct7"],"schemaLocalIds":[]}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @_reference_content | + | c2 | claim 1 | s1 | user1 | false | c1 | {} | {} | + | c8 | hypothesis 1 | s1 | user3 | false | c7 | {} | {} | + + # Note that the node is related to itself, unfortunate but hard to solve. + Scenario Outline: Query by author of related node + And a user logged in space s1 and calling getConcepts with these parameters: '{"schemaLocalIds":[],"inRelsToNodesOfAuthor":"user3","relationFields":["id"],"relationSubNodesFields":["id"]}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @_reference_content | + | c2 | claim 1 | s1 | user1 | false | c1 | {} | {} | + | c8 | hypothesis 1 | s1 | user3 | false | c7 | {} | {} | + + Scenario Outline: Query by related node + And a user logged in space s1 and calling getConcepts with these parameters: '{"schemaLocalIds":[],"inRelsToNodeLocalIds":["lct2"]}' + Then query results should look like this + | _id | name | _space_id | _author_id | @is_schema | _schema_id | @literal_content | @_reference_content | + | c2 | claim 1 | s1 | user1 | false | c1 | {} | {} | + | c3 | claim 2 | s1 | user2 | false | c1 | {} | {} | + | c8 | hypothesis 1 | s1 | user3 | false | c7 | {} | {} | diff --git a/packages/database/features/step-definitions/stepdefs.ts b/packages/database/features/step-definitions/stepdefs.ts index d9056556b..c7c8bb903 100644 --- a/packages/database/features/step-definitions/stepdefs.ts +++ b/packages/database/features/step-definitions/stepdefs.ts @@ -1,8 +1,15 @@ +/* eslint @typescript-eslint/no-explicit-any : 0 */ import assert from "assert"; import { Given, When, Then, world, type DataTable } from "@cucumber/cucumber"; import { createClient } from "@supabase/supabase-js"; -import { Constants, type Database, type Enums } from "@repo/database/dbTypes"; +import { + Constants, + type Database, + type Enums, + type Json, +} from "@repo/database/dbTypes"; import { getVariant, config } from "@repo/database/dbDotEnv"; +import { getConcepts, initNodeSchemaCache } from "@repo/database/lib/queries"; import { spaceAnonUserEmail, @@ -11,6 +18,7 @@ import { } from "@repo/database/lib/contextFunctions"; type Platform = Enums<"Platform">; +type TableName = keyof Database["public"]["Tables"]; const PLATFORMS: readonly Platform[] = Constants.public.Enums.Platform; if (getVariant() === "production") { @@ -26,20 +34,21 @@ const getAnonymousClient = () => { ); } return createClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_ANON_KEY!, + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY, ); }; const getServiceClient = () => { + // eslint-disable-next-line turboPlugin/no-undeclared-env-vars if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) { throw new Error( "Missing required environment variables: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY", ); } return createClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY, // eslint-disable-line turboPlugin/no-undeclared-env-vars ); }; @@ -49,6 +58,8 @@ Given("the database is blank", async () => { const client = getServiceClient(); let r = await client.from("Content").delete().neq("id", -1); assert.equal(r.error, null); + r = await client.from("Document").delete().neq("id", -1); + assert.equal(r.error, null); r = await client.from("Concept").delete().neq("id", -1); assert.equal(r.error, null); r = await client.from("AgentIdentifier").delete().neq("account_id", -1); @@ -57,43 +68,96 @@ Given("the database is blank", async () => { assert.equal(r.error, null); r = await client.from("Space").delete().neq("id", -1); assert.equal(r.error, null); + world.localRefs = {}; + // clear the cache + initNodeSchemaCache(); }); const substituteLocalReferences = ( - row: Record, + obj: any, + localRefs: Record, + prefixValue: boolean = false, +): any => { + const substituteLocalReferencesRec = (v: any): any => { + if (v === undefined || v === null) return v; + if (typeof v === "string") { + if (prefixValue) + return v.charAt(0) === "@" ? localRefs[v.substring(1)] : v; + else return localRefs[v]; + } + if (typeof v === "number" || typeof v === "boolean") return v; + if (Array.isArray(v)) return v.map(substituteLocalReferencesRec); + if (typeof v === "object") + return Object.fromEntries( + Object.entries(v as object).map(([k, v]) => [ + k, + substituteLocalReferencesRec(v), + ]), + ); + console.error("could not substitute", typeof v, v); + return v; + }; + return substituteLocalReferencesRec(obj); +}; + +const substituteLocalReferencesRow = ( + row: Record, localRefs: Record, -): Record => - Object.fromEntries( +): Record => { + const processKV = ([k, v]: [string, any]): [string, any] => { + const isJson = k.charAt(0) === "@"; + if (isJson) { + k = k.substring(1); + v = JSON.parse(v as string) as Json; + } + if (k.charAt(0) === "_") { + k = k.substring(1); + v = substituteLocalReferences(v, localRefs); // eslint-disable-line @typescript-eslint/no-unsafe-assignment + } + return [k, v]; + }; + + const result = Object.fromEntries( Object.entries(row) - .filter(([k, v]) => k.charAt(0) !== "@") - .map(([k, v]) => - k.charAt(0) == "_" ? [k.substring(1), localRefs[v]] : [k, v], - ), + .filter(([k]: [string, string]) => k.charAt(0) !== "$") + .map(processKV), ); + return result; +}; Given( "{word} are added to the database:", - async (tableName: keyof Database["public"]["Tables"], table: DataTable) => { - // generic function to add a bunch of objects. - // Columns prefixed by @ are primary keys, and are not sent to the database, - // but the local value is associated with the database id in world.localRefs. - // Columns prefixed with _ are translated back from local references to db ids. + async (tableName: TableName, table: DataTable) => { + // generic function to add a bunch of objects to an arbitrary table. + // Columns prefixed by $ are aliases for the primary keys, and are not sent to the database, + // but the alias name is associated with the database id in world.localRefs. + // Columns prefixed with _ are translated back from aliases to db ids. + // Columns prefixed with @ are parsed as json values. (Use @ before _) const client = getServiceClient(); - const localRefs: Record = world.localRefs || {}; + const localRefs = (world.localRefs || {}) as Record; const rows = table.hashes(); - const values: any[] = rows.map((r) => - substituteLocalReferences(r, localRefs), + const values: Record[] = rows.map((r) => + substituteLocalReferencesRow(r, localRefs), ); - const defIndex = table + const defIndex: string[] = table .raw()[0]! - .map((k) => (k.charAt(0) == "@" ? k : null)) + .map((k) => (k.charAt(0) == "$" ? k : null)) .filter((k) => typeof k == "string"); + const localIndexName: string = defIndex[0]!; + // do not allow to redefine values + assert.strictEqual( + values.filter((v) => + typeof v[localIndexName] === "string" + ? localRefs[v[localIndexName]] !== undefined + : false, + ).length, + 0, + ); if (defIndex.length) { - const localIndexName = defIndex[0]!; const dbIndexName = localIndexName.substring(1); const ids = await client .from(tableName) - .insert(values) + .insert(values as any[]) .select(dbIndexName); assert.equal(ids.error, null); if (ids.data == null || ids.data == undefined) @@ -105,7 +169,7 @@ Given( localRefs[rows[idx]![localIndexName]!] = dbId; } } else { - const r = await client.from(tableName).insert(values); + const r = await client.from(tableName).insert(values as any[]); assert.equal(r.error, null); } world.localRefs = localRefs; @@ -114,13 +178,15 @@ Given( const userEmail = (userAccountId: string) => `${userAccountId}@example.com`; +// Invoke the edge function to log an account into a database. +// Use this instead of trying to create spaces directly. When( "the user {word} opens the {word} plugin in space {word}", - async (userAccountId, platform, spaceName) => { + async (userAccountId: string, platform: Platform, spaceName: string) => { // assumption: turbo dev is running. TODO: Make into hooks if (PLATFORMS.indexOf(platform) < 0) - throw new Error(`Platform must be one of ${PLATFORMS}`); - const localRefs: Record = world.localRefs || {}; + throw new Error(`Platform must be one of ${PLATFORMS.join(", ")}`); + const localRefs = (world.localRefs || {}) as Record; const spaceResponse = await fetchOrCreateSpaceDirect({ password: SPACE_ANONYMOUS_PASSWORD, url: `https://roamresearch.com/#/app/${spaceName}`, @@ -146,15 +212,17 @@ When( }, ); -Then("the database should contain a {word}", async (tableName) => { +// A test of non-empty object count for the named table +Then("the database should contain a {word}", async (tableName: TableName) => { const client = getServiceClient(); const response = await client.from(tableName).select("*", { count: "exact" }); assert.notEqual(response.count || 0, 0); }); +// A test of absolute object count for the named table Then( "the database should contain {int} {word}", - async (expectedCount, tableName) => { + async (expectedCount: number, tableName: TableName) => { const client = getServiceClient(); const response = await client .from(tableName) @@ -163,16 +231,25 @@ Then( }, ); +const getLoggedinDatabase = async (spaceId: number) => { + assert.notStrictEqual(spaceId, undefined); + const client = getAnonymousClient(); + const loginResponse = await client.auth.signInWithPassword({ + email: spaceAnonUserEmail("Roam", spaceId), + password: SPACE_ANONYMOUS_PASSWORD, + }); + assert.equal(loginResponse.error, null); + return client; +}; + +// A test of non-empty object count for the named table, as seen by the user Then( "a user logged in space {word} should see a {word} in the database", - async (spaceName, tableName) => { - const client = getAnonymousClient(); - const spaceId = world.localRefs[spaceName]; - const loginResponse = await client.auth.signInWithPassword({ - email: spaceAnonUserEmail("Roam", spaceId), - password: SPACE_ANONYMOUS_PASSWORD, - }); - assert.equal(loginResponse.error, null); + async (spaceName: string, tableName: TableName) => { + const localRefs = (world.localRefs || {}) as Record; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const client = await getLoggedinDatabase(spaceId); const response = await client .from(tableName) .select("*", { count: "exact" }); @@ -180,16 +257,14 @@ Then( }, ); +// A test of exact object count for the named table, as seen by the user Then( "a user logged in space {word} should see {int} {word} in the database", - async (spaceName, expectedCount, tableName) => { - const client = getAnonymousClient(); - const spaceId = world.localRefs[spaceName]; - const loginResponse = await client.auth.signInWithPassword({ - email: spaceAnonUserEmail("Roam", spaceId), - password: SPACE_ANONYMOUS_PASSWORD, - }); - assert.equal(loginResponse.error, null); + async (spaceName: string, expectedCount: number, tableName: TableName) => { + const localRefs = (world.localRefs || {}) as Record; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const client = await getLoggedinDatabase(spaceId); const response = await client .from(tableName) .select("*", { count: "exact" }); @@ -197,61 +272,120 @@ Then( }, ); +// invoke the upsert_accounts_in_space function, expects json +Given( + "user {word} upserts these accounts to space {word}:", + async (userName: string, spaceName: string, accountsString: string) => { + const accounts = JSON.parse(accountsString) as Json; + const localRefs = (world.localRefs || {}) as Record; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const client = await getLoggedinDatabase(spaceId); + const response = await client.rpc("upsert_accounts_in_space", { + space_id_: spaceId, // eslint-disable-line @typescript-eslint/naming-convention + accounts, + }); + assert.equal(response.error, null); + }, +); + +// invoke the upsert_documents function, expects json Given( "user {word} upserts these documents to space {word}:", async (userName: string, spaceName: string, docString: string) => { - const data = JSON.parse(docString); - const client = getAnonymousClient(); - const spaceId = world.localRefs[spaceName]; - const loginResponse = await client.auth.signInWithPassword({ - email: spaceAnonUserEmail("Roam", spaceId), - password: SPACE_ANONYMOUS_PASSWORD, - }); - assert.equal(loginResponse.error, null); + const data = JSON.parse(docString) as Json; + const localRefs = (world.localRefs || {}) as Record; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_documents", { - v_space_id: spaceId, + v_space_id: spaceId, // eslint-disable-line @typescript-eslint/naming-convention data, }); assert.equal(response.error, null); }, ); +// invoke the upsert_content function, expects json Given( "user {word} upserts this content to space {word}:", async (userName: string, spaceName: string, docString: string) => { - const data = JSON.parse(docString); - const client = getAnonymousClient(); - const spaceId = world.localRefs[spaceName]; - const loginResponse = await client.auth.signInWithPassword({ - email: spaceAnonUserEmail("Roam", spaceId), - password: SPACE_ANONYMOUS_PASSWORD, - }); - assert.equal(loginResponse.error, null); + const data = JSON.parse(docString) as Json; + const localRefs = (world.localRefs || {}) as Record; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const userId = localRefs[userName]; + if (userId === undefined) assert.fail("userId"); + const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_content", { - v_space_id: spaceId, + v_space_id: spaceId, // eslint-disable-line @typescript-eslint/naming-convention data, - v_creator_id: world.localRefs[userName], - content_as_document: false, + v_creator_id: userId, // eslint-disable-line @typescript-eslint/naming-convention + content_as_document: false, // eslint-disable-line @typescript-eslint/naming-convention }); assert.equal(response.error, null); }, ); +// invoke the upsert_concepts function, expects json Given( "user {word} upserts these concepts to space {word}:", async (userName: string, spaceName: string, docString: string) => { - const data = JSON.parse(docString); - const client = getAnonymousClient(); - const spaceId = world.localRefs[spaceName]; - const loginResponse = await client.auth.signInWithPassword({ - email: spaceAnonUserEmail("Roam", spaceId), - password: SPACE_ANONYMOUS_PASSWORD, - }); - assert.equal(loginResponse.error, null); + const data = JSON.parse(docString) as Json; + const localRefs = (world.localRefs || {}) as Record; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const client = await getLoggedinDatabase(spaceId); const response = await client.rpc("upsert_concepts", { - v_space_id: spaceId, + v_space_id: spaceId, // eslint-disable-line @typescript-eslint/naming-convention data, }); assert.equal(response.error, null); }, ); + +Given( + "a user logged in space {word} and calling getConcepts with these parameters: {string}", + async (spaceName: string, paramsJ: string) => { + // params are assumed to be Json. Values prefixed with '@' are interpreted as aliases. + const localRefs = (world.localRefs || {}) as Record; + const params = substituteLocalReferences( + JSON.parse(paramsJ), + localRefs, + true, + ) as object; + const spaceId = localRefs[spaceName]; + if (spaceId === undefined) assert.fail("spaceId"); + const supabase = await getLoggedinDatabase(spaceId); + // note that we supply spaceId and supabase, they do not need to be part of the incoming Json + const nodes = await getConcepts({ ...params, supabase, spaceId }); + nodes.sort((a, b) => a.id! - b.id!); + world.queryResults = nodes; + }, +); + +type ObjectWithId = object & { id: number }; + +Then("query results should look like this", (table: DataTable) => { + const localRefs = (world.localRefs || {}) as Record; + const rows = table.hashes(); + const values = rows.map((r) => + substituteLocalReferencesRow(r, localRefs), + ) as ObjectWithId[]; + // console.debug(values); + // console.debug(JSON.stringify(world.queryResults, null, 2)); + const queryResults = (world.queryResults || []) as ObjectWithId[]; + values.sort((a, b) => a.id - b.id); + assert.deepEqual( + queryResults.map((v) => v.id), + values.map((v) => v.id), + ); + if (values.length > 0) { + const keys = Object.keys(values[0]!); + const truncatedResults = queryResults.map((v: object) => + Object.fromEntries(Object.entries(v).filter(([k]) => keys.includes(k))), + ); + // console.debug(truncatedResults); + assert.deepEqual(truncatedResults, values); + } +}); diff --git a/packages/database/src/dbTypes.ts b/packages/database/src/dbTypes.ts index 4c15c8623..dc48a6a4c 100644 --- a/packages/database/src/dbTypes.ts +++ b/packages/database/src/dbTypes.ts @@ -578,10 +578,50 @@ export type Database = { Args: { lit_content: Json; schema_id: number } Returns: number } + concept_in_relations: { + Args: { concept: Database["public"]["Tables"]["Concept"]["Row"] } + Returns: { + arity: number | null + author_id: number | null + created: string + description: string | null + epistemic_status: Database["public"]["Enums"]["EpistemicStatus"] + id: number + is_schema: boolean + last_modified: string + literal_content: Json + name: string + reference_content: Json + refs: number[] + represented_by_id: number | null + schema_id: number | null + space_id: number + }[] + } concept_in_space: { Args: { concept_id: number } Returns: boolean } + concepts_of_relation: { + Args: { relation: Database["public"]["Tables"]["Concept"]["Row"] } + Returns: { + arity: number | null + author_id: number | null + created: string + description: string | null + epistemic_status: Database["public"]["Enums"]["EpistemicStatus"] + id: number + is_schema: boolean + last_modified: string + literal_content: Json + name: string + reference_content: Json + refs: number[] + represented_by_id: number | null + schema_id: number | null + space_id: number + }[] + } content_in_space: { Args: { content_id: number } Returns: boolean diff --git a/packages/database/src/inputTypes.ts b/packages/database/src/inputTypes.ts index 758025ab9..b71e3bfd6 100644 --- a/packages/database/src/inputTypes.ts +++ b/packages/database/src/inputTypes.ts @@ -1,4 +1,4 @@ -import type { Database, TablesInsert } from "@repo/database/dbTypes"; +import type { Database } from "@repo/database/dbTypes"; export type LocalAccountDataInput = Partial< Database["public"]["CompositeTypes"]["account_local_input"] diff --git a/packages/database/src/lib/contextFunctions.ts b/packages/database/src/lib/contextFunctions.ts index 8117df59c..d82e0403e 100644 --- a/packages/database/src/lib/contextFunctions.ts +++ b/packages/database/src/lib/contextFunctions.ts @@ -2,10 +2,7 @@ import type { Enums, Tables, TablesInsert } from "@repo/database/dbTypes"; import type { PostgrestSingleResponse } from "@supabase/supabase-js"; import type { FunctionsResponse } from "@supabase/functions-js"; import { nextApiRoot } from "@repo/utils/execContext"; -import { - createClient, - type DGSupabaseClient, -} from "@repo/database/lib/client"; +import { createClient, type DGSupabaseClient } from "@repo/database/lib/client"; export const spaceAnonUserEmail = (platform: string, space_id: number) => `${platform.toLowerCase()}-${space_id}-anon@database.discoursegraphs.com`; @@ -123,8 +120,8 @@ export const createLoggedInClient = async ( platform: Platform, spaceId: number, password: string, -): Promise => { - const loggedInClient: DGSupabaseClient|null = createClient(); +): Promise => { + const loggedInClient: DGSupabaseClient | null = createClient(); if (!loggedInClient) return null; const { error } = await loggedInClient.auth.signInWithPassword({ email: spaceAnonUserEmail(platform, spaceId), diff --git a/packages/database/src/lib/queries.ts b/packages/database/src/lib/queries.ts index eeb6d1056..b9d1374ee 100644 --- a/packages/database/src/lib/queries.ts +++ b/packages/database/src/lib/queries.ts @@ -2,7 +2,7 @@ import { PostgrestResponse } from "@supabase/supabase-js"; import type { Tables } from "../dbTypes"; import { DGSupabaseClient } from "./client"; -// the functions you are most likely to use are getNodeSchemas and getNodes. +// the functions you are most likely to use are getSchemaConcepts and getConcepts. type Concept = Tables<"Concept">; type Content = Tables<"Content">; @@ -26,56 +26,140 @@ export const nodeSchemaSignature: NodeSignature = { type CacheMissTimestamp = number; type CacheEntry = NodeSignature | CacheMissTimestamp; -let NODE_SCHEMA_CACHE: Record = { +const NODE_SCHEMA_CACHE: Record = { [NODE_SCHEMAS]: nodeSchemaSignature, }; +export const initNodeSchemaCache = () => { + Object.keys(NODE_SCHEMA_CACHE).map((k) => { + if (k !== NODE_SCHEMAS) delete NODE_SCHEMA_CACHE[k]; + }); +}; + +/* eslint-disable @typescript-eslint/naming-convention */ export type PDocument = Partial>; export type PContent = Partial> & { - Document: PDocument | null; + Document?: PDocument | null; +}; +export type PAccount = Partial>; +export type PConceptBase = Partial>; +export type PConceptSubNode = PConceptBase & { + Concept?: { source_local_id: string } | null; + author?: { account_local_id: string } | null; }; -export type PConcept = Partial> & { - Content: PContent | null; - schema_of_concept: { name: string } | null; +export type PRelConcept = PConceptBase & { + subnodes?: PConceptSubNode[]; }; -type defaultQueryShape = { +export type PConceptFull = PConceptBase & { + Content?: PContent | null; + author?: PAccount; + relations?: PRelConcept[]; +}; + +type DefaultQueryShape = { id: number; space_id: number; name: string; Content: { source_local_id: string }; }; +/* eslint-enable @typescript-eslint/naming-convention */ // Utility function to compose a generic query to fetch concepts, content and document. -// - schemaDbIds = 0 → fetch schemas (is_schema = true) -// - schemaDbIds = n → fetch nodes under schema with dbId n (is_schema = false, eq schema_id) -// - schemaDbIds = [] → fetch all nodes (is_schema = false, no filter on schema_id) -// - schemaDbIds = [a,b,...] → fetch nodes under any of those schemas -const composeQuery = ({ +// Arguments are as in getConcepts, except we use numeric db ids of concepts for schemas instead +// their respective content's source_local_id. +const composeConceptQuery = ({ supabase, spaceId, + baseNodeLocalIds = [], schemaDbIds = 0, + fetchNodes = true, + nodeAuthor = undefined, + inRelsOfType = undefined, + inRelsToNodesOfType = undefined, + inRelsToNodesOfAuthor = undefined, + inRelsToNodeLocalIds = undefined, conceptFields = ["id", "name", "space_id"], contentFields = ["source_local_id"], documentFields = [], + relationFields = undefined, + relationSubNodesFields = undefined, + limit = 100, + offset = 0, }: { supabase: DGSupabaseClient; spaceId?: number; schemaDbIds?: number | number[]; + baseNodeLocalIds?: string[]; + fetchNodes?: boolean | null; + nodeAuthor?: string; + inRelsOfType?: number[]; + relationSubNodesFields?: (keyof Concept)[]; + inRelsToNodesOfType?: number[]; + inRelsToNodesOfAuthor?: string; conceptFields?: (keyof Concept)[]; contentFields?: (keyof Content)[]; documentFields?: (keyof Document)[]; + relationFields?: (keyof Concept)[]; + inRelsToNodeLocalIds?: string[]; + limit?: number; + offset?: number; }) => { let q = conceptFields.join(",\n"); + const innerContent = schemaDbIds === 0 || baseNodeLocalIds.length > 0; + if (innerContent && !contentFields.includes("source_local_id")) { + contentFields = contentFields.slice(); + contentFields.push("source_local_id"); + } if (contentFields.length > 0) { - q += ",\nContent (\n" + contentFields.join(",\n"); + const args: string[] = contentFields.slice(); if (documentFields.length > 0) { - q += ",\nDocument (\n" + documentFields.join(",\n") + ")"; + args.push("Document (\n" + documentFields.join(",\n") + ")"); + } + q += `,\nContent${innerContent ? "!inner" : ""} (\n${args.join(",\n")})`; + } + if (nodeAuthor !== undefined) { + q += ", author:author_id!inner(account_local_id)"; + } + if ( + inRelsOfType !== undefined || + inRelsToNodesOfType !== undefined || + inRelsToNodesOfAuthor !== undefined || + inRelsToNodeLocalIds !== undefined + ) { + const args: string[] = (relationFields || []).slice(); + if (inRelsOfType !== undefined && !args.includes("schema_id")) + args.push("schema_id"); + if ( + inRelsToNodesOfType !== undefined || + inRelsToNodesOfAuthor !== undefined || + inRelsToNodeLocalIds !== undefined + ) { + const args2: string[] = (relationSubNodesFields || []).slice(); + if (inRelsToNodesOfType !== undefined && !args2.includes("schema_id")) + args2.push("schema_id"); + if (inRelsToNodeLocalIds !== undefined) + args2.push("Content!inner(source_local_id)"); + if (inRelsToNodesOfAuthor !== undefined) { + if (!args2.includes("author_id")) args2.push("author_id"); + args2.push("author:author_id!inner(account_local_id)"); + } + args.push(`subnodes:concepts_of_relation!inner(${args2.join(",\n")})`); } - q += ")"; + q += `, relations:concept_in_relations!inner(${args.join(",\n")})`; } - let query = supabase.from("Concept").select(q).eq("arity", 0); + let query = supabase.from("Concept").select(q); + if (fetchNodes === true) { + query = query.eq("arity", 0); + } else if (fetchNodes === false) { + query = query.gt("arity", 0); + } + // else fetch both + if (spaceId !== undefined) query = query.eq("space_id", spaceId); + if (nodeAuthor !== undefined) { + query = query.eq("author.account_local_id", nodeAuthor); + } if (schemaDbIds === 0) { query = query.eq("is_schema", true); } else { @@ -87,11 +171,39 @@ const composeQuery = ({ query = query.eq("schema_id", schemaDbIds); else throw new Error("schemaDbIds should be a number or number[]"); } + if (baseNodeLocalIds.length > 0) + query = query.in("content.source_local_id", baseNodeLocalIds); + if (inRelsOfType !== undefined && inRelsOfType.length > 0) + query = query.in("relations.schema_id", inRelsOfType); + if (inRelsToNodesOfType !== undefined && inRelsToNodesOfType.length > 0) + query = query.in("relations.subnodes.schema_id", inRelsToNodesOfType); + if (inRelsToNodesOfAuthor !== undefined) { + query = query.eq( + "relations.subnodes.author.account_local_id", + inRelsToNodesOfAuthor, + ); + } + if (inRelsToNodeLocalIds !== undefined) { + query = query.in( + "relations.subnodes.Content.source_local_id", + inRelsToNodeLocalIds, + ); + } + if (limit > 0 || offset > 0) { + query = query.order("id"); + if (offset > 0) { + limit = Math.min(limit, 1000); + query = query.range(offset, offset + limit); + } else if (limit > 0) { + query = query.limit(limit); + } + } + // console.debug(query); return query; }; // Obtain basic data for all node schemas in a space, populating the cache. -export const getNodeSchemas = async ( +export const getSchemaConcepts = async ( supabase: DGSupabaseClient, spaceId: number, forceCacheReload: boolean = false, @@ -100,15 +212,15 @@ export const getNodeSchemas = async ( .filter((x) => typeof x === "object") .filter((x) => x.spaceId === spaceId || x.spaceId === 0); if (forceCacheReload || result.length === 1) { - const q = composeQuery({ supabase, spaceId }); - const res = (await q) as PostgrestResponse; + const q = composeConceptQuery({ supabase, spaceId, fetchNodes: null }); + const res = (await q) as PostgrestResponse; if (res.error) { - console.error("getNodeSchemas failed", res.error); + console.error("getSchemaConcepts failed", res.error); return [NODE_SCHEMA_CACHE[NODE_SCHEMAS] as NodeSignature]; } - NODE_SCHEMA_CACHE = { - ...NODE_SCHEMA_CACHE, - ...Object.fromEntries( + Object.assign( + NODE_SCHEMA_CACHE, + Object.fromEntries( res.data.map((x) => [ x.Content.source_local_id, { @@ -119,7 +231,7 @@ export const getNodeSchemas = async ( }, ]), ), - }; + ); result = Object.values(NODE_SCHEMA_CACHE) .filter((x) => typeof x === "object") .filter((x) => x.spaceId === spaceId || x.spaceId === 0); @@ -146,7 +258,7 @@ const getLocalToDbIdMapping = async ( const numMissing = Object.values(dbIds).filter((x) => x === null).length; if (numMissing === 0) return dbIds; const previousMisses = Object.fromEntries( - partialResult.filter(([k, v]) => typeof v === "number"), + partialResult.filter(([, v]) => typeof v === "number"), ) as Record; const numPreviousMisses = Object.values(previousMisses).length; const now = Date.now(); @@ -157,21 +269,21 @@ const getLocalToDbIdMapping = async ( console.warn("Cannot populate cache without spaceId"); return dbIds; } - let q = composeQuery({ supabase, spaceId }); + let q = composeConceptQuery({ supabase, spaceId, fetchNodes: null }); if (Object.keys(NODE_SCHEMA_CACHE).length > 1) { // Non-empty cache, query selectively q = q .in("Content.source_local_id", localLocalIds) .not("Content.source_local_id", "is", null); } // otherwise populate the cache - const res = (await q) as PostgrestResponse; + const res = (await q) as PostgrestResponse; if (res.error) { console.error("could not get db Ids", res.error); return dbIds; } - NODE_SCHEMA_CACHE = { - ...NODE_SCHEMA_CACHE, - ...Object.fromEntries( + Object.assign( + NODE_SCHEMA_CACHE, + Object.fromEntries( res.data.map((x) => [ x.Content.source_local_id, { @@ -182,7 +294,7 @@ const getLocalToDbIdMapping = async ( }, ]), ), - }; + ); for (const localId of localLocalIds) { if (typeof NODE_SCHEMA_CACHE[localId] !== "object") NODE_SCHEMA_CACHE[localId] = now; @@ -207,6 +319,7 @@ export const CONCEPT_FIELDS: (keyof Concept)[] = [ "reference_content", "refs", "is_schema", + "schema_id", "represented_by_id", ]; @@ -235,58 +348,115 @@ export const DOCUMENT_FIELDS: (keyof Document)[] = [ "author_id", ]; -// get all nodes that belong to a certain number of schemas. -// This query will return Concept objects, and associated Content and Document, -// according to which fields are requested. Defaults to maximal information. -// Main call options: -// • ALL schemas: schemaLocalIds = "__schemas" (default) -// • ALL nodes (instances): schemaLocalIds = [] -// • Nodes from X,Y schemas: schemaLocalIds = ["localIdX","localIdY",...] -export const getNodes = async ({ - supabase, - spaceId, - schemaLocalIds = NODE_SCHEMAS, - conceptFields = CONCEPT_FIELDS, - contentFields = CONTENT_FIELDS, - documentFields = DOCUMENT_FIELDS, +// instrumentation for benchmarking +export const LAST_QUERY_DATA = { duration: 0 }; + +// Main entry point to query Concepts and related data: +// related sub-objects can be provided as: +// Content, Content.Document, author (PlatformAccount), relations (Concept), +// relations.subnodes (Concept), relations.subnodes.author, relations.subnodes.Content +// Which fields of these subobjects are fetched is controlled by the respective Fields parameters +// (except the last two, which would have just enough data for query filters.) +// If the fields are empty, the sub-object will not be fetched (unless needed for matching query parameters) +// Any parameter called "local" expects platform Ids (source_local_id) of the corresponding Content. +// In the case of node/relation definitions, schema refers to the page Id of the definition. +export const getConcepts = async ({ + supabase, // An instance of a logged-in client + spaceId, // the numeric id of the space being queried + baseNodeLocalIds = [], // If we are specifying the Concepts being queried directly. + schemaLocalIds = NODE_SCHEMAS, // the type of Concepts being queried + // • ALL schemas: schemaLocalIds = NODE_SCHEMAS (default, "__schemas") + // • ALL instances (nodes and/or relations): schemaLocalIds = [] + // • Nodes from X,Y schemas: schemaLocalIds = ["localIdX","localIdY",...] + fetchNodes = true, // are we fetching nodes or relations? + // true for nodes, false for relations, null for both + nodeAuthor = undefined, // filter on Content author + inRelsOfTypeLocal = undefined, // filter on Concepts that participate in a relation of a given type + inRelsToNodesOfTypeLocal = undefined, // filter on Concepts that are in a relation with another node of a given type + inRelsToNodesOfAuthor = undefined, // filter on Concepts that are in a relation with another Concept by a given author + inRelsToNodeLocalIds = undefined, // filter on Concepts that are in relation with a Concept from a given list + conceptFields = CONCEPT_FIELDS, // which fields are returned for the given Concept + contentFields = CONTENT_FIELDS, // which fields are returned for the corresponding Content + documentFields = DOCUMENT_FIELDS, // which fields are returned for the Content's corresponding Document + relationFields = undefined, // which fields are returned for the relation the node is part of + relationSubNodesFields = undefined, // which fields are returned for the other nodes in the relation the target node is part of + limit = 100, // query limit + offset = 0, // query offset }: { supabase: DGSupabaseClient; spaceId?: number; + baseNodeLocalIds?: string[]; schemaLocalIds?: string | string[]; + fetchNodes?: boolean | null; + nodeAuthor?: string; + inRelsOfTypeLocal?: string[]; + inRelsToNodesOfTypeLocal?: string[]; + inRelsToNodesOfAuthor?: string; + inRelsToNodeLocalIds?: string[]; conceptFields?: (keyof Concept)[]; contentFields?: (keyof Content)[]; documentFields?: (keyof Document)[]; -}): Promise => { - let schemaDbIds: number | number[] = 0; - const localIdsArray = + relationFields?: (keyof Concept)[]; + relationSubNodesFields?: (keyof Concept)[]; + limit?: number; + offset?: number; +}): Promise => { + const schemaLocalIdsArray = typeof schemaLocalIds === "string" ? [schemaLocalIds] : schemaLocalIds; - if (schemaLocalIds !== NODE_SCHEMAS) { - const dbIdsMapping = await getLocalToDbIdMapping( - supabase, - localIdsArray, - spaceId, - ); - schemaDbIds = Object.values(dbIdsMapping).filter((x) => x !== null); - if (schemaDbIds.length < localIdsArray.length) { + // translate schema local content Ids to concept database Ids. + const localIds = new Set(schemaLocalIdsArray); + if (inRelsOfTypeLocal !== undefined) + inRelsOfTypeLocal.map((k) => localIds.add(k)); + if (inRelsToNodesOfTypeLocal !== undefined) + inRelsToNodesOfTypeLocal.map((k) => localIds.add(k)); + const dbIdsMapping = await getLocalToDbIdMapping( + supabase, + new Array(...localIds.keys()), + spaceId, + ); + const localToDbArray = (a: string[] | undefined): number[] | undefined => { + if (a === undefined) return undefined; + const r = a + .map((k) => dbIdsMapping[k]) + .filter((k) => k !== null && k !== undefined); + if (r.length < a.length) { console.error( "Some localIds are not yet in database: ", - localIdsArray - .filter((localId) => dbIdsMapping[localId] === null) - .join(", "), + a.filter((k) => !dbIdsMapping[k]).join(", "), ); } - } - const q = composeQuery({ + return r; + }; + const schemaDbIds = + schemaLocalIds === NODE_SCHEMAS ? 0 : localToDbArray(schemaLocalIdsArray); + + const q = composeConceptQuery({ supabase, spaceId, + baseNodeLocalIds, schemaDbIds, conceptFields, contentFields, documentFields, + nodeAuthor, + fetchNodes, + inRelsOfType: localToDbArray(inRelsOfTypeLocal), + relationFields, + relationSubNodesFields, + inRelsToNodesOfType: localToDbArray(inRelsToNodesOfTypeLocal), + inRelsToNodesOfAuthor, + inRelsToNodeLocalIds, + limit, + offset, }); - const { error, data } = (await q) as PostgrestResponse; + const before = Date.now(); + const { error, data } = (await q) as PostgrestResponse; + LAST_QUERY_DATA.duration = Date.now() - before; + // benchmarking + // console.debug(LAST_QUERY_DATA.duration, q); + if (error) { - console.error("getNodes failed", error); + console.error("getConcepts failed", error); return []; } return data || []; diff --git a/packages/database/supabase/migrations/20250929154709_relation_access_functions.sql b/packages/database/supabase/migrations/20250929154709_relation_access_functions.sql new file mode 100644 index 000000000..a7ef6efac --- /dev/null +++ b/packages/database/supabase/migrations/20250929154709_relation_access_functions.sql @@ -0,0 +1,20 @@ + +CREATE OR REPLACE FUNCTION public.concept_in_relations(concept "Concept") + RETURNS SETOF "Concept" + LANGUAGE sql + STABLE STRICT + SET search_path TO '' +AS $function$ + SELECT * from public."Concept" WHERE refs @> ARRAY[concept.id]; +$function$ +; + +CREATE OR REPLACE FUNCTION public.concepts_of_relation(relation "Concept") + RETURNS SETOF "Concept" + LANGUAGE sql + STABLE STRICT + SET search_path TO '' +AS $function$ + SELECT * from public."Concept" WHERE id = any(relation.refs); +$function$ +; diff --git a/packages/database/supabase/schemas/concept.sql b/packages/database/supabase/schemas/concept.sql index 6620a3d48..623b76884 100644 --- a/packages/database/supabase/schemas/concept.sql +++ b/packages/database/supabase/schemas/concept.sql @@ -160,6 +160,28 @@ $$; COMMENT ON FUNCTION public.instances_of_schema(public."Concept") IS 'Computed one-to-many: returns all Concept instances that are based on the given schema Concept.'; + +CREATE OR REPLACE FUNCTION public.concept_in_relations(concept public."Concept") +RETURNS SETOF public."Concept" STRICT STABLE +SET search_path = '' +LANGUAGE sql +AS $$ + SELECT * from public."Concept" WHERE refs @> ARRAY[concept.id]; +$$; +COMMENT ON FUNCTION public.concept_in_relations(public."Concept") +IS 'Computed one-to-many: returns all Concept instances that are relations including the current concept.'; + +CREATE OR REPLACE FUNCTION public.concepts_of_relation(relation public."Concept") +RETURNS SETOF public."Concept" STRICT STABLE +SET search_path = '' +LANGUAGE sql +AS $$ + SELECT * from public."Concept" WHERE id = any(relation.refs); +$$; +COMMENT ON FUNCTION public.concepts_of_relation(public."Concept") +IS 'Computed one-to-many: returns all Concept instances are referred to in the current concept.'; + + -- private function. Transform concept with local (platform) references to concept with db references CREATE OR REPLACE FUNCTION public._local_concept_to_db_concept(data public.concept_local_input) RETURNS public."Concept" STABLE