Repository: danielroe/zero-vue
Pull request #52: feat: upgrade to zero v0.18
Author: login: maxstevens-nl
Created: 2025-04-04T09:42:40Z | Merged: 2025-04-08T23:54:49Z

This PR makes `zero-vue` compatible with Zero v0.18. The following has been changed:

- Ability to pass in TTL parameter to `useQuery`.
- Call `createSchema` without versionnumber.
- Removal of the `#refCountMap` in `VueView`, since the `applyChange` function with `RefCountMap` has been deprecated.
- Replace all the (now deprecated) imports of `@rocicorp/zero/advanced` with `@rocicorp/zero`
- Unittests for `useQuery` and `VueView`, based on the [tests for Solid](https://github.com/rocicorp/mono/blob/main/packages/zero-solid/src/use-query.test.ts).

Notes:
- The Solid implementation of `useQuery` has methods to keep track of the RefCount. If I understand https://github.com/danielroe/zero-vue/issues/48 correctly, we don't need to do anything along those lines from zero 0.18 onwards. Is that correct?
- We don't test using a (virtual) remote at the moment. This means that the status of a query will always remain unknown. I have commented out the corresponding asserts for now.

Closes:
- resolves https://github.com/danielroe/zero-vue/issues/48
- resolves https://github.com/danielroe/zero-vue/issues/12


Diff:
diff --git a/package.json b/package.json
index 4c1ad00..f0c4d86 100644
--- a/package.json
+++ b/package.json
@@ -35,10 +35,11 @@
     "vue": "^3.5.13"
   },
   "dependencies": {
-    "@rocicorp/zero": "^0.17.0"
+    "@rocicorp/zero": "^0.18.0"
   },
   "devDependencies": {
     "@antfu/eslint-config": "latest",
+    "@rocicorp/resolver": "^1.0.2",
     "@vitest/coverage-v8": "latest",
     "bumpp": "latest",
     "changelogithub": "13.13.0",
diff --git a/playground/src/db/schema.ts b/playground/src/db/schema.ts
index 078ff26..9419533 100644
--- a/playground/src/db/schema.ts
+++ b/playground/src/db/schema.ts
@@ -55,7 +55,7 @@ const messageRelationships = relationships(message, ({ one }) => ({
   }),
 }))
 
-export const schema = createSchema(1, {
+export const schema = createSchema({
   tables: [user, medium, message],
   relationships: [messageRelationships],
 })
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bb47f45..23102b3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,12 +12,15 @@ importers:
   .:
     dependencies:
       '@rocicorp/zero':
-        specifier: ^0.17.0
-        version: 0.17.2025030701
+        specifier: ^0.18.0
+        version: 0.18.2025040300
     devDependencies:
       '@antfu/eslint-config':
         specifier: latest
         version: 4.11.0(@typescript-eslint/utils@8.27.0(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(@vue/compiler-sfc@3.5.13)(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.0))
+      '@rocicorp/resolver':
+        specifier: ^1.0.2
+        version: 1.0.2
       '@vitest/coverage-v8':
         specifier: latest
         version: 3.1.1(vitest@3.1.1(@types/debug@4.1.12)(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.0))
@@ -197,9 +200,19 @@ packages:
   '@databases/validate-unicode@1.0.0':
     resolution: {integrity: sha512-dLKqxGcymeVwEb/6c44KjOnzaAafFf0Wxa8xcfEjx/qOl3rdijsKYBAtIGhtVtOlpPf/PFKfgTuFurSPn/3B/g==}
 
+  '@dotenvx/dotenvx@1.39.0':
+    resolution: {integrity: sha512-qGfDpL/3S17MQYXpR3HkBS5xNQ7wiFlqLdpr+iIQzv17aMRcSlgL4EjMIsYFZ540Dq17J+y5FVElA1AkVoXiUA==}
+    hasBin: true
+
   '@drdgvhbh/postgres-error-codes@0.0.6':
     resolution: {integrity: sha512-tAz0Xp+qhq90x0r/3VW96iRdHFw72cYQqXa65u0eFVhSMC27bc2gZ8Ky5WXEmshrl/bCe7QTYBNEF0U5zeSQjw==}
 
+  '@ecies/ciphers@0.2.3':
+    resolution: {integrity: sha512-tapn6XhOueMwht3E2UzY0ZZjYokdaw9XtL9kEyjhQ/Fb9vL9xTFbOaI+fV0AWvTpYu4BNloC6getKW6NtSg4mA==}
+    engines: {bun: '>=1', deno: '>=2', node: '>=16'}
+    peerDependencies:
+      '@noble/ciphers': ^1.0.0
+
   '@emnapi/core@1.3.1':
     resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==}
 
@@ -667,6 +680,18 @@ packages:
   '@napi-rs/wasm-runtime@0.2.7':
     resolution: {integrity: sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==}
 
+  '@noble/ciphers@1.2.1':
+    resolution: {integrity: sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==}
+    engines: {node: ^14.21.3 || >=16}
+
+  '@noble/curves@1.8.1':
+    resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==}
+    engines: {node: ^14.21.3 || >=16}
+
+  '@noble/hashes@1.7.1':
+    resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==}
+    engines: {node: ^14.21.3 || >=16}
+
   '@nodelib/fs.scandir@2.1.5':
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -948,8 +973,8 @@ packages:
     resolution: {integrity: sha512-bm+VUdF4CnKVjUj/dSCmVu0hjcyXaF/nKkw2rNhZUjGeBOMRy/hh8z/32h311es4dxCVvcZ3+QHQHMxF2YG5Kw==}
     hasBin: true
 
-  '@rocicorp/zero@0.17.2025030701':
-    resolution: {integrity: sha512-ywskUYhbmNZyBH8LkLrL9Zbz7g0zrCv62ZtyxH5ls5KWcMmUNRcw1nqfuqhzUfy1GXOYzEJQHVCc0hQMkvBGng==}
+  '@rocicorp/zero@0.18.2025040300':
+    resolution: {integrity: sha512-4RRHLdqhofF353gHqaQLffEdFPDHJldpdGfSdjXd+ncby6PPUXsF8eDTXAhzJbdwiSGq3jtXDgS9qrq1aKzLwQ==}
     engines: {node: '>=20'}
     hasBin: true
 
@@ -1727,6 +1752,10 @@ packages:
     resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
     engines: {node: '>=12.20.0'}
 
+  commander@11.1.0:
+    resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
+    engines: {node: '>=16'}
+
   commander@13.1.0:
     resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
     engines: {node: '>=18'}
@@ -1931,6 +1960,10 @@ packages:
   easy-table@1.2.0:
     resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==}
 
+  eciesjs@0.4.14:
+    resolution: {integrity: sha512-eJAgf9pdv214Hn98FlUzclRMYWF7WfoLlkS9nWMTm1qcCwn6Ad4EGD9lr9HXMBfSrZhYQujRE+p0adPRkctC6A==}
+    engines: {bun: '>=1', deno: '>=2', node: '>=16'}
+
   electron-to-chromium@1.5.113:
     resolution: {integrity: sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==}
 
@@ -2179,12 +2212,13 @@ packages:
     resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
     engines: {node: '>=0.10.0'}
 
-  eventemitter2@6.4.9:
-    resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
-
   eventemitter3@5.0.1:
     resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
 
+  execa@5.1.1:
+    resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+    engines: {node: '>=10'}
+
   execa@8.0.1:
     resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
     engines: {node: '>=16.17'}
@@ -2326,6 +2360,10 @@ packages:
     resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
     engines: {node: '>=18'}
 
+  get-stream@6.0.1:
+    resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+    engines: {node: '>=10'}
+
   get-stream@8.0.1:
     resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
     engines: {node: '>=16'}
@@ -2400,6 +2438,10 @@ packages:
   html-escaper@2.0.2:
     resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
 
+  human-signals@2.1.0:
+    resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+    engines: {node: '>=10.17.0'}
+
   human-signals@5.0.0:
     resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
     engines: {node: '>=16.17.0'}
@@ -2513,6 +2555,10 @@ packages:
   is-reference@1.2.1:
     resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
 
+  is-stream@2.0.1:
+    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+    engines: {node: '>=8'}
+
   is-stream@3.0.0:
     resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -2532,6 +2578,10 @@ packages:
   isexe@2.0.0:
     resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
 
+  isexe@3.1.1:
+    resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
+    engines: {node: '>=16'}
+
   istanbul-lib-coverage@3.2.2:
     resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
     engines: {node: '>=8'}
@@ -2854,6 +2904,10 @@ packages:
     resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
     engines: {node: '>=8.6'}
 
+  mimic-fn@2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+
   mimic-fn@4.0.0:
     resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
     engines: {node: '>=12'}
@@ -2991,6 +3045,10 @@ packages:
     resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
 
+  npm-run-path@4.0.1:
+    resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+    engines: {node: '>=8'}
+
   npm-run-path@5.3.0:
     resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3012,6 +3070,10 @@ packages:
     engines: {node: ^14.16.0 || >=16.10.0}
     hasBin: true
 
+  object-treeify@1.1.33:
+    resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
+    engines: {node: '>= 10'}
+
   obliterator@2.0.5:
     resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
 
@@ -3031,6 +3093,10 @@ packages:
   once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 
+  onetime@5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+
   onetime@6.0.0:
     resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
     engines: {node: '>=12'}
@@ -3123,48 +3189,10 @@ packages:
   perfect-debounce@1.0.0:
     resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
 
-  pg-cloudflare@1.1.1:
-    resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
-
-  pg-connection-string@2.7.0:
-    resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==}
-
   pg-format-fix@1.0.5:
     resolution: {integrity: sha512-HcXVy9Zk4kn87P0+U9XSxGtenNyknbPB87NreixSBk0lYJy89u+d/zQbS+f/aTTxABQ/B6FH1KdBB5EsGzRS2w==}
     engines: {node: '>=4.0'}
 
-  pg-int8@1.0.1:
-    resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
-    engines: {node: '>=4.0.0'}
-
-  pg-logical-replication@2.0.7:
-    resolution: {integrity: sha512-G6fyOYN9jIz4RdKC2t2NrYp5nw8aZ7cMfH0BOISYwl4M16DWg8+LYQZbY8LMyGurmms6+igALbsgpnCOS9a5Fw==}
-    engines: {node: '>= 14.0.0'}
-
-  pg-pool@3.7.1:
-    resolution: {integrity: sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==}
-    peerDependencies:
-      pg: '>=8.0'
-
-  pg-protocol@1.7.1:
-    resolution: {integrity: sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==}
-
-  pg-types@2.2.0:
-    resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
-    engines: {node: '>=4'}
-
-  pg@8.13.3:
-    resolution: {integrity: sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==}
-    engines: {node: '>= 8.0.0'}
-    peerDependencies:
-      pg-native: '>=3.0.1'
-    peerDependenciesMeta:
-      pg-native:
-        optional: true
-
-  pgpass@1.0.5:
-    resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
-
   picocolors@1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 
@@ -3391,26 +3419,6 @@ packages:
     resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
     engines: {node: ^10 || ^12 || >=14}
 
-  postgres-array@2.0.0:
-    resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
-    engines: {node: '>=4'}
-
-  postgres-array@3.0.4:
-    resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
-    engines: {node: '>=12'}
-
-  postgres-bytea@1.0.0:
-    resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
-    engines: {node: '>=0.10.0'}
-
-  postgres-date@1.0.7:
-    resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
-    engines: {node: '>=0.10.0'}
-
-  postgres-interval@1.2.0:
-    resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
-    engines: {node: '>=0.10.0'}
-
   postgres@3.4.5:
     resolution: {integrity: sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==}
     engines: {node: '>=12'}
@@ -3424,6 +3432,11 @@ packages:
     resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
     engines: {node: '>= 0.8.0'}
 
+  prettier@3.5.3:
+    resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
+    engines: {node: '>=14'}
+    hasBin: true
+
   pretty-bytes@6.1.1:
     resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
     engines: {node: ^14.13.1 || >=16.0.0}
@@ -3617,6 +3630,9 @@ packages:
   siginfo@2.0.0:
     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 
+  signal-exit@3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
   signal-exit@4.1.0:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
@@ -3718,6 +3734,10 @@ packages:
     resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==}
     engines: {node: '>=8'}
 
+  strip-final-newline@2.0.0:
+    resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+    engines: {node: '>=6'}
+
   strip-final-newline@3.0.0:
     resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
     engines: {node: '>=12'}
@@ -4037,6 +4057,11 @@ packages:
     engines: {node: '>= 8'}
     hasBin: true
 
+  which@4.0.0:
+    resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
+    engines: {node: ^16.13.0 || >=18.0.0}
+    hasBin: true
+
   why-is-node-running@2.3.0:
     resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
     engines: {node: '>=8'}
@@ -4081,10 +4106,6 @@ packages:
     resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
     engines: {node: '>=12'}
 
-  xtend@4.0.2:
-    resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
-    engines: {node: '>=0.4'}
-
   y18n@5.0.8:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}
@@ -4230,8 +4251,24 @@ snapshots:
 
   '@databases/validate-unicode@1.0.0': {}
 
+  '@dotenvx/dotenvx@1.39.0':
+    dependencies:
+      commander: 11.1.0
+      dotenv: 16.4.7
+      eciesjs: 0.4.14
+      execa: 5.1.1
+      fdir: 6.4.3(picomatch@4.0.2)
+      ignore: 5.3.2
+      object-treeify: 1.1.33
+      picomatch: 4.0.2
+      which: 4.0.0
+
   '@drdgvhbh/postgres-error-codes@0.0.6': {}
 
+  '@ecies/ciphers@0.2.3(@noble/ciphers@1.2.1)':
+    dependencies:
+      '@noble/ciphers': 1.2.1
+
   '@emnapi/core@1.3.1':
     dependencies:
       '@emnapi/wasi-threads': 1.0.1
@@ -4582,6 +4619,14 @@ snapshots:
       '@tybys/wasm-util': 0.9.0
     optional: true
 
+  '@noble/ciphers@1.2.1': {}
+
+  '@noble/curves@1.8.1':
+    dependencies:
+      '@noble/hashes': 1.7.1
+
+  '@noble/hashes@1.7.1': {}
+
   '@nodelib/fs.scandir@2.1.5':
     dependencies:
       '@nodelib/fs.stat': 2.0.5
@@ -4899,11 +4944,12 @@ snapshots:
       bindings: 1.5.0
       prebuild-install: 7.1.3
 
-  '@rocicorp/zero@0.17.2025030701':
+  '@rocicorp/zero@0.18.2025040300':
     dependencies:
       '@badrap/valita': 0.3.11
       '@databases/escape-identifier': 1.0.3
       '@databases/sql': 3.3.0
+      '@dotenvx/dotenvx': 1.39.0
       '@drdgvhbh/postgres-error-codes': 0.0.6
       '@fastify/cors': 10.1.0
       '@fastify/websocket': 11.0.2
@@ -4924,7 +4970,6 @@ snapshots:
       command-line-usage: 7.0.3
       compare-utf8: 0.1.1
       defu: 6.1.4
-      dotenv: 16.4.7
       eventemitter3: 5.0.1
       fastify: 5.2.1
       jose: 5.10.0
@@ -4932,12 +4977,9 @@ snapshots:
       json-custom-numbers: 3.1.1
       kasi: 1.1.1
       nanoid: 5.1.3
-      pg: 8.13.3
       pg-format: pg-format-fix@1.0.5
-      pg-logical-replication: 2.0.7
-      pg-protocol: 1.7.1
       postgres: 3.4.5
-      postgres-array: 3.0.4
+      prettier: 3.5.3
       semver: 7.7.1
       tsx: 4.19.3
       url-pattern: 1.0.3
@@ -4945,7 +4987,6 @@ snapshots:
     transitivePeerDependencies:
       - '@75lb/nature'
       - bufferutil
-      - pg-native
       - supports-color
       - utf-8-validate
 
@@ -5796,6 +5837,8 @@ snapshots:
       table-layout: 4.1.1
       typical: 7.3.0
 
+  commander@11.1.0: {}
+
   commander@13.1.0: {}
 
   commander@4.1.1: {}
@@ -5997,6 +6040,13 @@ snapshots:
     optionalDependencies:
       wcwidth: 1.0.1
 
+  eciesjs@0.4.14:
+    dependencies:
+      '@ecies/ciphers': 0.2.3(@noble/ciphers@1.2.1)
+      '@noble/ciphers': 1.2.1
+      '@noble/curves': 1.8.1
+      '@noble/hashes': 1.7.1
+
   electron-to-chromium@1.5.113: {}
 
   emoji-regex@10.4.0: {}
@@ -6376,10 +6426,20 @@ snapshots:
 
   esutils@2.0.3: {}
 
-  eventemitter2@6.4.9: {}
-
   eventemitter3@5.0.1: {}
 
+  execa@5.1.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      get-stream: 6.0.1
+      human-signals: 2.1.0
+      is-stream: 2.0.1
+      merge-stream: 2.0.0
+      npm-run-path: 4.0.1
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+      strip-final-newline: 2.0.0
+
   execa@8.0.1:
     dependencies:
       cross-spawn: 7.0.6
@@ -6538,6 +6598,8 @@ snapshots:
 
   get-east-asian-width@1.3.0: {}
 
+  get-stream@6.0.1: {}
+
   get-stream@8.0.1: {}
 
   get-stream@9.0.1:
@@ -6613,6 +6675,8 @@ snapshots:
 
   html-escaper@2.0.2: {}
 
+  human-signals@2.1.0: {}
+
   human-signals@5.0.0: {}
 
   human-signals@8.0.0: {}
@@ -6706,6 +6770,8 @@ snapshots:
     dependencies:
       '@types/estree': 1.0.6
 
+  is-stream@2.0.1: {}
+
   is-stream@3.0.0: {}
 
   is-stream@4.0.1: {}
@@ -6718,6 +6784,8 @@ snapshots:
 
   isexe@2.0.0: {}
 
+  isexe@3.1.1: {}
+
   istanbul-lib-coverage@3.2.2: {}
 
   istanbul-lib-report@3.0.1:
@@ -7227,6 +7295,8 @@ snapshots:
       braces: 3.0.3
       picomatch: 2.3.1
 
+  mimic-fn@2.1.0: {}
+
   mimic-fn@4.0.0: {}
 
   mimic-function@5.0.1: {}
@@ -7335,6 +7405,10 @@ snapshots:
 
   npm-normalize-package-bin@3.0.1: {}
 
+  npm-run-path@4.0.1:
+    dependencies:
+      path-key: 3.1.1
+
   npm-run-path@5.3.0:
     dependencies:
       path-key: 4.0.0
@@ -7365,6 +7439,8 @@ snapshots:
       pkg-types: 2.1.0
       tinyexec: 0.3.2
 
+  object-treeify@1.1.33: {}
+
   obliterator@2.0.5: {}
 
   ofetch@1.4.1:
@@ -7383,6 +7459,10 @@ snapshots:
     dependencies:
       wrappy: 1.0.2
 
+  onetime@5.1.2:
+    dependencies:
+      mimic-fn: 2.1.0
+
   onetime@6.0.0:
     dependencies:
       mimic-fn: 4.0.0
@@ -7469,50 +7549,8 @@ snapshots:
 
   perfect-debounce@1.0.0: {}
 
-  pg-cloudflare@1.1.1:
-    optional: true
-
-  pg-connection-string@2.7.0: {}
-
   pg-format-fix@1.0.5: {}
 
-  pg-int8@1.0.1: {}
-
-  pg-logical-replication@2.0.7:
-    dependencies:
-      eventemitter2: 6.4.9
-      pg: 8.13.3
-    transitivePeerDependencies:
-      - pg-native
-
-  pg-pool@3.7.1(pg@8.13.3):
-    dependencies:
-      pg: 8.13.3
-
-  pg-protocol@1.7.1: {}
-
-  pg-types@2.2.0:
-    dependencies:
-      pg-int8: 1.0.1
-      postgres-array: 2.0.0
-      postgres-bytea: 1.0.0
-      postgres-date: 1.0.7
-      postgres-interval: 1.2.0
-
-  pg@8.13.3:
-    dependencies:
-      pg-connection-string: 2.7.0
-      pg-pool: 3.7.1(pg@8.13.3)
-      pg-protocol: 1.7.1
-      pg-types: 2.2.0
-      pgpass: 1.0.5
-    optionalDependencies:
-      pg-cloudflare: 1.1.1
-
-  pgpass@1.0.5:
-    dependencies:
-      split2: 4.2.0
-
   picocolors@1.1.1: {}
 
   picomatch@2.3.1: {}
@@ -7733,18 +7771,6 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
-  postgres-array@2.0.0: {}
-
-  postgres-array@3.0.4: {}
-
-  postgres-bytea@1.0.0: {}
-
-  postgres-date@1.0.7: {}
-
-  postgres-interval@1.2.0:
-    dependencies:
-      xtend: 4.0.2
-
   postgres@3.4.5: {}
 
   prebuild-install@7.1.3:
@@ -7764,6 +7790,8 @@ snapshots:
 
   prelude-ls@1.2.1: {}
 
+  prettier@3.5.3: {}
+
   pretty-bytes@6.1.1: {}
 
   pretty-ms@9.2.0:
@@ -7989,6 +8017,8 @@ snapshots:
 
   siginfo@2.0.0: {}
 
+  signal-exit@3.0.7: {}
+
   signal-exit@4.1.0: {}
 
   simple-concat@1.0.1: {}
@@ -8086,6 +8116,8 @@ snapshots:
 
   strip-bom@4.0.0: {}
 
+  strip-final-newline@2.0.0: {}
+
   strip-final-newline@3.0.0: {}
 
   strip-final-newline@4.0.0: {}
@@ -8446,6 +8478,10 @@ snapshots:
     dependencies:
       isexe: 2.0.0
 
+  which@4.0.0:
+    dependencies:
+      isexe: 3.1.1
+
   why-is-node-running@2.3.0:
     dependencies:
       siginfo: 2.0.0
@@ -8479,8 +8515,6 @@ snapshots:
 
   xml-name-validator@4.0.0: {}
 
-  xtend@4.0.2: {}
-
   y18n@5.0.8: {}
 
   yallist@4.0.0: {}
diff --git a/src/index.ts b/src/index.ts
index 345d709..4ae50f5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1 @@
-export { useQuery } from './query'
+export { useQuery, type UseQueryOptions } from './query'
diff --git a/src/query.test.ts b/src/query.test.ts
new file mode 100644
index 0000000..0d63221
--- /dev/null
+++ b/src/query.test.ts
@@ -0,0 +1,222 @@
+import type { TTL } from '@rocicorp/zero'
+import { createSchema, number, string, table, Zero } from '@rocicorp/zero'
+import { describe, expect, it, vi } from 'vitest'
+import { ref, watchEffect } from 'vue'
+import { useQuery } from './query'
+import { vueViewFactory } from './view'
+
+async function setupTestEnvironment() {
+  const schema = createSchema({
+    tables: [
+      table('table')
+        .columns({
+          a: number(),
+          b: string(),
+        })
+        .primaryKey('a'),
+    ],
+  })
+
+  const z = new Zero({
+    userID: 'asdf',
+    server: null,
+    schema,
+    // This is often easier to develop with if you're frequently changing
+    // the schema. Switch to 'idb' for local-persistence.
+    kvStore: 'mem',
+  })
+
+  await z.mutate.table.insert({ a: 1, b: 'a' })
+  await z.mutate.table.insert({ a: 2, b: 'b' })
+
+  const tableQuery = z.query.table
+
+  return { z, tableQuery }
+}
+
+describe('useQuery', () => {
+  it('useQuery', async () => {
+    const { z, tableQuery } = await setupTestEnvironment()
+
+    const { data: rows, status } = useQuery(() => tableQuery)
+    expect(rows.value).toMatchInlineSnapshot(`[
+  {
+    "a": 1,
+    "b": "a",
+    Symbol(rc): 1,
+  },
+  {
+    "a": 2,
+    "b": "b",
+    Symbol(rc): 1,
+  },
+]`)
+    expect(status.value).toEqual('unknown')
+
+    await z.mutate.table.insert({ a: 3, b: 'c' })
+    await 1
+
+    expect(rows.value).toMatchInlineSnapshot(`[
+  {
+    "a": 1,
+    "b": "a",
+    Symbol(rc): 1,
+  },
+  {
+    "a": 2,
+    "b": "b",
+    Symbol(rc): 1,
+  },
+  {
+    "a": 3,
+    "b": "c",
+    Symbol(rc): 1,
+  },
+]`)
+
+    // TODO: this is not working at the moment, possibly because we don't have a server connection in test
+    // expect(resultType.value).toEqual("complete");
+
+    z.close()
+  })
+
+  it('useQuery with ttl', async () => {
+    const { z, tableQuery } = await setupTestEnvironment()
+    const ttl = ref<TTL>('1m')
+
+    const materializeSpy = vi.spyOn(tableQuery, 'materialize')
+    const updateTTLSpy = vi.spyOn(tableQuery, 'updateTTL')
+    const queryGetter = vi.fn(() => tableQuery)
+
+    useQuery(queryGetter, () => ({ ttl: ttl.value }))
+
+    expect(queryGetter).toHaveBeenCalledTimes(1)
+    expect(updateTTLSpy).toHaveBeenCalledTimes(0)
+    expect(materializeSpy).toHaveBeenCalledExactlyOnceWith(
+      vueViewFactory,
+      '1m',
+    )
+    materializeSpy.mockClear()
+
+    ttl.value = '10m'
+    await 1
+
+    expect(materializeSpy).toHaveBeenCalledTimes(0)
+    expect(updateTTLSpy).toHaveBeenCalledExactlyOnceWith('10m')
+
+    z.close()
+  })
+
+  it('useQuery deps change', async () => {
+    const { z, tableQuery } = await setupTestEnvironment()
+
+    const a = ref(1)
+
+    const { data: rows, status } = useQuery(() =>
+      tableQuery.where('a', a.value),
+    )
+
+    const rowLog: unknown[] = []
+    const resultDetailsLog: unknown[] = []
+    const resetLogs = () => {
+      rowLog.length = 0
+      resultDetailsLog.length = 0
+    }
+
+    watchEffect(() => {
+      rowLog.push(rows.value)
+    })
+
+    watchEffect(() => {
+      resultDetailsLog.push(status.value)
+    })
+
+    expect(rowLog).toMatchInlineSnapshot(`[
+  [
+    {
+      "a": 1,
+      "b": "a",
+      Symbol(rc): 1,
+    },
+  ],
+]`)
+    // expect(resultDetailsLog).toEqual(["unknown"]);
+    resetLogs()
+
+    expect(rowLog).toEqual([])
+    // expect(resultDetailsLog).toEqual(["complete"]);
+    resetLogs()
+
+    a.value = 2
+    await 1
+
+    expect(rowLog).toMatchInlineSnapshot(`[
+  [
+    {
+      "a": 2,
+      "b": "b",
+      Symbol(rc): 1,
+    },
+  ],
+]`)
+    // expect(resultDetailsLog).toEqual(["unknown"]);
+    resetLogs()
+
+    expect(rowLog).toEqual([])
+    // expect(resultDetailsLog).toEqual(["complete"]);
+
+    z.close()
+  })
+
+  it('useQuery deps change watchEffect', async () => {
+    const { z, tableQuery } = await setupTestEnvironment()
+    const a = ref(1)
+    const { data: rows } = useQuery(() => tableQuery.where('a', a.value))
+
+    let run = 0
+
+    await new Promise((resolve) => {
+      watchEffect(() => {
+        if (run === 0) {
+          expect(rows.value).toMatchInlineSnapshot(
+            `[
+  {
+    "a": 1,
+    "b": "a",
+    Symbol(rc): 1,
+  },
+]`,
+          )
+          z.mutate.table.update({ a: 1, b: 'a2' })
+        }
+        else if (run === 1) {
+          expect(rows.value).toMatchInlineSnapshot(
+            `[
+  {
+    "a": 1,
+    "b": "a2",
+    Symbol(rc): 1,
+  },
+]`,
+          )
+          a.value = 2
+        }
+        else if (run === 2) {
+          expect(rows.value).toMatchInlineSnapshot(
+            `[
+  {
+    "a": 2,
+    "b": "b",
+    Symbol(rc): 1,
+  },
+]`,
+          )
+          resolve(true)
+        }
+        run++
+      })
+    })
+
+    z.close()
+  })
+})
diff --git a/src/query.ts b/src/query.ts
index a8c236b..e4b7c3b 100644
--- a/src/query.ts
+++ b/src/query.ts
@@ -1,12 +1,24 @@
 // based on https://github.com/rocicorp/mono/tree/main/packages/zero-solid
 
-import type { ResultType, Schema } from '@rocicorp/zero'
-import type { AdvancedQuery, HumanReadable, Query } from '@rocicorp/zero/advanced'
+import type { HumanReadable, Query, ResultType, Schema, TTL } from '@rocicorp/zero'
 import type { ComputedRef, MaybeRefOrGetter } from 'vue'
+import type { VueView } from './view'
 
-import { computed, getCurrentInstance, isRef, onUnmounted, shallowRef, toValue, watch } from 'vue'
+import { DEFAULT_TTL } from '@rocicorp/zero'
+import {
+  computed,
+  getCurrentInstance,
+  onUnmounted,
+  shallowRef,
+  toValue,
+  watch,
+} from 'vue'
 import { vueViewFactory } from './view'
 
+export interface UseQueryOptions {
+  ttl?: TTL | undefined
+}
+
 interface QueryResult<TReturn> {
   data: ComputedRef<HumanReadable<TReturn>>
   status: ComputedRef<ResultType>
@@ -16,23 +28,34 @@ export function useQuery<
   TSchema extends Schema,
   TTable extends keyof TSchema['tables'] & string,
   TReturn,
->(_query: MaybeRefOrGetter<Query<TSchema, TTable, TReturn>>): QueryResult<TReturn> {
-  const query = toValue(_query) as AdvancedQuery<TSchema, TTable, TReturn>
-  const view = shallowRef(query.materialize(vueViewFactory))
-
-  if (isRef(_query) || typeof _query === 'function') {
-    watch(_query, (query) => {
-      view.value.destroy()
-      view.value = (query as AdvancedQuery<TSchema, TTable, TReturn>).materialize(vueViewFactory)
-    })
-  }
+>(
+  query: MaybeRefOrGetter<Query<TSchema, TTable, TReturn>>,
+  options?: MaybeRefOrGetter<UseQueryOptions>,
+): QueryResult<TReturn> {
+  const ttl = computed(() => {
+    return toValue(options)?.ttl ?? DEFAULT_TTL
+  })
+  const view = shallowRef<VueView<HumanReadable<TReturn>> | null>(null)
+
+  watch(
+    () => toValue(query),
+    (q) => {
+      view.value?.destroy()
+      view.value = q.materialize(vueViewFactory, ttl.value)
+    },
+    { immediate: true },
+  )
+
+  watch(ttl, (ttl) => {
+    toValue(query).updateTTL(ttl)
+  })
 
   if (getCurrentInstance()) {
-    onUnmounted(() => view.value.destroy())
+    onUnmounted(() => view.value!.destroy())
   }
 
   return {
-    data: computed(() => view.value.data),
-    status: computed(() => view.value.status),
+    data: computed(() => view.value!.data),
+    status: computed(() => view.value!.status),
   }
 }
diff --git a/src/view.test.ts b/src/view.test.ts
new file mode 100644
index 0000000..4e13fd7
--- /dev/null
+++ b/src/view.test.ts
@@ -0,0 +1,1318 @@
+import type { Query, Schema } from '@rocicorp/zero'
+
+import { resolver } from '@rocicorp/resolver'
+import {
+  createSchema,
+  number,
+  relationships,
+  string,
+  table,
+  Zero,
+} from '@rocicorp/zero'
+import { describe, expect, it, vi } from 'vitest'
+import { VueView, vueViewFactory } from './view'
+
+const simpleSchema = createSchema({
+  tables: [
+    table('table')
+      .columns({
+        a: number(),
+        b: string(),
+      })
+      .primaryKey('a'),
+  ],
+})
+
+const recursiveTable = table('table')
+  .columns({
+    id: number(),
+    name: string(),
+    data: string().optional(),
+    childID: number().optional(),
+  })
+  .primaryKey('id')
+const treeSchema = createSchema({
+  tables: [recursiveTable],
+  relationships: [
+    relationships(recursiveTable, ({ many }) => ({
+      children: many({
+        sourceField: ['childID'],
+        destSchema: recursiveTable,
+        destField: ['id'],
+      }),
+    })),
+  ],
+})
+
+const issue = table('issue')
+  .columns({
+    id: number(),
+    name: string(),
+  })
+  .primaryKey('id')
+
+const label = table('label')
+  .columns({
+    id: number(),
+    name: string(),
+  })
+  .primaryKey('id')
+
+const issueLabel = table('issueLabel')
+  .columns({
+    id: number(),
+    issueID: number(),
+    labelID: number(),
+    extra: string(),
+  })
+  .primaryKey('id')
+
+const collapseSchema = createSchema({
+  tables: [issue, label, issueLabel],
+  relationships: [
+    relationships(issue, ({ many }) => ({
+      labels: many(
+        {
+          sourceField: ['id'],
+          destSchema: issueLabel,
+          destField: ['issueID'],
+        },
+        {
+          sourceField: ['labelID'],
+          destSchema: label,
+          destField: ['id'],
+        },
+      ),
+    })),
+  ],
+})
+
+async function setupTestEnvironment<S extends Schema>(schema: S) {
+  const z = new Zero({
+    userID: 'asdf',
+    server: null,
+    schema,
+    // This is often easier to develop with if you're frequently changing
+    // the schema. Switch to 'idb' for local-persistence.
+    kvStore: 'mem',
+  })
+
+  return { z }
+}
+
+describe('vueView', () => {
+  it('basics', async () => {
+    const { z } = await setupTestEnvironment(simpleSchema)
+    const tableQuery = z.query.table
+
+    await z.mutate.table.insert({ a: 1, b: 'a' })
+    await z.mutate.table.insert({ a: 2, b: 'b' })
+
+    const view = tableQuery.materialize(vueViewFactory)
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 1,
+        "b": "a",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 2,
+        "b": "b",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    // TODO: Test with a real resolver
+    // expect(view.status).toEqual("complete");
+
+    await z.mutate.table.insert({ a: 3, b: 'c' })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 1,
+        "b": "a",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 2,
+        "b": "b",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 3,
+        "b": "c",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.delete({ a: 1 })
+    await z.mutate.table.delete({ a: 2 })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 3,
+        "b": "c",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.delete({ a: 3 })
+
+    expect(view.data).toEqual([])
+
+    z.close()
+  })
+
+  it.skip('basics-perf', async () => {
+    const iterations = 10_000
+
+    const { z } = await setupTestEnvironment(simpleSchema)
+    const tableQuery = z.query.table
+
+    const view = tableQuery.materialize(vueViewFactory)
+    expect(view.data.length).toBe(0)
+
+    for (const i in [...Array.from({ length: iterations }).keys()]) {
+      await z.mutate.table.insert({ a: Number(i), b: 'a' })
+    }
+
+    expect(view.data.length).toBe(iterations)
+
+    z.close()
+  })
+
+  it('hydrate-empty', async () => {
+    const { z } = await setupTestEnvironment(simpleSchema)
+    const tableQuery = z.query.table
+
+    const view = tableQuery.materialize(vueViewFactory)
+
+    expect(view.data).toEqual([])
+
+    z.close()
+  })
+
+  it('tree', async () => {
+    const { z } = await setupTestEnvironment(treeSchema)
+
+    await z.mutate.table.insert({ id: 1, name: 'foo', data: null, childID: 2 })
+    await z.mutate.table.insert({
+      id: 2,
+      name: 'foobar',
+      data: null,
+      childID: null,
+    })
+    await z.mutate.table.insert({ id: 3, name: 'mon', data: null, childID: 4 })
+    await z.mutate.table.insert({
+      id: 4,
+      name: 'monkey',
+      data: null,
+      childID: null,
+    })
+
+    const query = z.query.table.related('children')
+    const view = query.materialize(vueViewFactory)
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 2,
+            "name": "foobar",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 2,
+        "name": "foobar",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.insert({ id: 5, name: 'chocolate', childID: 2 })
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 2,
+            "name": "foobar",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 2,
+        "name": "foobar",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 2,
+            "name": "foobar",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 5,
+        "name": "chocolate",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.delete({ id: 2 })
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [],
+        "data": null,
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 2,
+        "children": [],
+        "data": null,
+        "id": 5,
+        "name": "chocolate",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.insert({
+      id: 2,
+      name: 'foobaz',
+      childID: null,
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 2,
+            "name": "foobaz",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 2,
+        "name": "foobaz",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": null,
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": null,
+            "id": 2,
+            "name": "foobaz",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": null,
+        "id": 5,
+        "name": "chocolate",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+  })
+
+  it('tree-single', async () => {
+    const { z } = await setupTestEnvironment(treeSchema)
+
+    await z.mutate.table.insert({ id: 1, name: 'foo', childID: 2 })
+    await z.mutate.table.insert({ id: 2, name: 'foobar', childID: null })
+
+    const query = z.query.table.related('children').one()
+    const view = query.materialize(vueViewFactory)
+
+    expect(view.data).toMatchInlineSnapshot(`
+    {
+      "childID": 2,
+      "children": [
+        {
+          "childID": null,
+          "data": null,
+          "id": 2,
+          "name": "foobar",
+          Symbol(rc): 1,
+        },
+      ],
+      "data": null,
+      "id": 1,
+      "name": "foo",
+      Symbol(rc): 1,
+    }
+  `)
+
+    // remove the child
+    await z.mutate.table.delete({ id: 2 })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    {
+      "childID": 2,
+      "children": [],
+      "data": null,
+      "id": 1,
+      "name": "foo",
+      Symbol(rc): 1,
+    }
+  `)
+
+    // remove the parent
+    await z.mutate.table.delete({ id: 1 })
+    expect(view.data).toEqual(undefined)
+
+    z.close()
+  })
+
+  it('collapse', async () => {
+    const { z } = await setupTestEnvironment(collapseSchema)
+    const query = z.query.issue.related('labels')
+    const view = query.materialize(vueViewFactory)
+
+    expect(view.data).toEqual([])
+
+    const changeSansType = {
+      node: {
+        row: {
+          id: 1,
+          name: 'issue',
+        },
+        relationships: {
+          labels: () => [
+            {
+              row: {
+                id: 1,
+                issueId: 1,
+                labelId: 1,
+                extra: 'a',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 1,
+                      name: 'label',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+          ],
+        },
+      },
+    } as const
+
+    view.push({
+      type: 'add',
+      ...changeSansType,
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "id": 1,
+        "labels": [
+          {
+            "id": 1,
+            "name": "label",
+            Symbol(rc): 1,
+          },
+        ],
+        "name": "issue",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    view.push({
+      type: 'remove',
+      ...changeSansType,
+    })
+    expect(view.data).toEqual([])
+
+    view.push({
+      type: 'add',
+      ...changeSansType,
+    })
+
+    view.push({
+      type: 'child',
+      node: {
+        row: {
+          id: 1,
+          name: 'issue',
+        },
+        relationships: {
+          labels: () => [
+            {
+              row: {
+                id: 1,
+                issueId: 1,
+                labelId: 1,
+                extra: 'a',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 1,
+                      name: 'label',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+            {
+              row: {
+                id: 2,
+                issueId: 1,
+                labelId: 2,
+                extra: 'b',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 2,
+                      name: 'label2',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+          ],
+        },
+      },
+      child: {
+        relationshipName: 'labels',
+        change: {
+          type: 'add',
+          node: {
+            row: {
+              id: 2,
+              issueId: 1,
+              labelId: 2,
+              extra: 'b',
+            },
+            relationships: {
+              labels: () => [
+                {
+                  row: {
+                    id: 2,
+                    name: 'label2',
+                  },
+                  relationships: {},
+                },
+              ],
+            },
+          },
+        },
+      },
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "id": 1,
+        "labels": [
+          {
+            "id": 1,
+            "name": "label",
+            Symbol(rc): 1,
+          },
+          {
+            "id": 2,
+            "name": "label2",
+            Symbol(rc): 1,
+          },
+        ],
+        "name": "issue",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    // edit the hidden row
+    view.push({
+      type: 'child',
+      node: {
+        row: {
+          id: 1,
+          name: 'issue',
+        },
+        relationships: {
+          labels: () => [
+            {
+              row: {
+                id: 1,
+                issueId: 1,
+                labelId: 1,
+                extra: 'a',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 1,
+                      name: 'label',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+            {
+              row: {
+                id: 2,
+                issueId: 1,
+                labelId: 2,
+                extra: 'b2',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 2,
+                      name: 'label2',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+          ],
+        },
+      },
+      child: {
+        relationshipName: 'labels',
+        change: {
+          type: 'edit',
+          oldNode: {
+            row: {
+              id: 2,
+              issueId: 1,
+              labelId: 2,
+              extra: 'b',
+            },
+            relationships: {
+              labels: () => [
+                {
+                  row: {
+                    id: 2,
+                    name: 'label2',
+                  },
+                  relationships: {},
+                },
+              ],
+            },
+          },
+          node: {
+            row: {
+              id: 2,
+              issueId: 1,
+              labelId: 2,
+              extra: 'b2',
+            },
+            relationships: {
+              labels: () => [
+                {
+                  row: {
+                    id: 2,
+                    name: 'label2',
+                  },
+                  relationships: {},
+                },
+              ],
+            },
+          },
+        },
+      },
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "id": 1,
+        "labels": [
+          {
+            "id": 1,
+            "name": "label",
+            Symbol(rc): 1,
+          },
+          {
+            "id": 2,
+            "name": "label2",
+            Symbol(rc): 1,
+          },
+        ],
+        "name": "issue",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    // edit the leaf
+    view.push({
+      type: 'child',
+      node: {
+        row: {
+          id: 1,
+          name: 'issue',
+        },
+        relationships: {
+          labels: () => [
+            {
+              row: {
+                id: 1,
+                issueId: 1,
+                labelId: 1,
+                extra: 'a',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 1,
+                      name: 'label',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+            {
+              row: {
+                id: 2,
+                issueId: 1,
+                labelId: 2,
+                extra: 'b2',
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 2,
+                      name: 'label2x',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+          ],
+        },
+      },
+      child: {
+        relationshipName: 'labels',
+        change: {
+          type: 'child',
+          node: {
+            row: {
+              id: 2,
+              issueId: 1,
+              labelId: 2,
+              extra: 'b2',
+            },
+            relationships: {
+              labels: () => [
+                {
+                  row: {
+                    id: 2,
+                    name: 'label2x',
+                  },
+                  relationships: {},
+                },
+              ],
+            },
+          },
+          child: {
+            relationshipName: 'labels',
+            change: {
+              type: 'edit',
+              oldNode: {
+                row: {
+                  id: 2,
+                  name: 'label2',
+                },
+                relationships: {},
+              },
+              node: {
+                row: {
+                  id: 2,
+                  name: 'label2x',
+                },
+                relationships: {},
+              },
+            },
+          },
+        },
+      },
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "id": 1,
+        "labels": [
+          {
+            "id": 1,
+            "name": "label",
+            Symbol(rc): 1,
+          },
+          {
+            "id": 2,
+            "name": "label2x",
+            Symbol(rc): 1,
+          },
+        ],
+        "name": "issue",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    z.close()
+  })
+
+  it('collapse-single', async () => {
+    const { z } = await setupTestEnvironment(collapseSchema)
+    const query = z.query.issue.related('labels')
+    const view = query.materialize(vueViewFactory)
+
+    expect(view.data).toEqual([])
+
+    const changeSansType = {
+      node: {
+        row: {
+          id: 1,
+          name: 'issue',
+        },
+        relationships: {
+          labels: () => [
+            {
+              row: {
+                id: 1,
+                issueId: 1,
+                labelId: 1,
+              },
+              relationships: {
+                labels: () => [
+                  {
+                    row: {
+                      id: 1,
+                      name: 'label',
+                    },
+                    relationships: {},
+                  },
+                ],
+              },
+            },
+          ],
+        },
+      },
+    } as const
+    view.push({
+      type: 'add',
+      ...changeSansType,
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "id": 1,
+        "labels": [
+          {
+            "id": 1,
+            "name": "label",
+            Symbol(rc): 1,
+          },
+        ],
+        "name": "issue",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    z.close()
+  })
+
+  it('basic with edit pushes', async () => {
+    const { z } = await setupTestEnvironment(simpleSchema)
+    await z.mutate.table.insert({ a: 1, b: 'a' })
+    await z.mutate.table.insert({ a: 2, b: 'b' })
+
+    const query = z.query.table
+    const view = query.materialize(vueViewFactory)
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 1,
+        "b": "a",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 2,
+        "b": "b",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.update({ a: 2, b: 'b2' })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 1,
+        "b": "a",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 2,
+        "b": "b2",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    await z.mutate.table.insert({ a: 3, b: 'b3' })
+    await z.mutate.table.delete({ a: 2 })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 1,
+        "b": "a",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 3,
+        "b": "b3",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    z.close()
+  })
+
+  it('tree edit', async () => {
+    const { z } = await setupTestEnvironment(treeSchema)
+
+    await z.mutate.table.insert({ id: 1, name: 'foo', data: 'a', childID: 2 })
+    await z.mutate.table.insert({
+      id: 2,
+      name: 'foobar',
+      data: 'b',
+      childID: null,
+    })
+    await z.mutate.table.insert({ id: 3, name: 'mon', data: 'c', childID: 4 })
+    await z.mutate.table.insert({
+      id: 4,
+      name: 'monkey',
+      data: 'd',
+      childID: null,
+    })
+
+    const query = z.query.table.related('children')
+    const view = query.materialize(vueViewFactory)
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": "b",
+            "id": 2,
+            "name": "foobar",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": "a",
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": "b",
+        "id": 2,
+        "name": "foobar",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": "d",
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": "c",
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": "d",
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    // Edit root
+    await z.mutate.table.update({ id: 1, data: 'a2' })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": "b",
+            "id": 2,
+            "name": "foobar",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": "a2",
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": "b",
+        "id": 2,
+        "name": "foobar",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": "d",
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": "c",
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": "d",
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    // Edit leaf
+    await z.mutate.table.update({ id: 4, data: 'd2' })
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "childID": 2,
+        "children": [
+          {
+            "childID": null,
+            "data": "b",
+            "id": 2,
+            "name": "foobar",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": "a2",
+        "id": 1,
+        "name": "foo",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": "b",
+        "id": 2,
+        "name": "foobar",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": 4,
+        "children": [
+          {
+            "childID": null,
+            "data": "d2",
+            "id": 4,
+            "name": "monkey",
+            Symbol(rc): 1,
+          },
+        ],
+        "data": "c",
+        "id": 3,
+        "name": "mon",
+        Symbol(rc): 1,
+      },
+      {
+        "childID": null,
+        "children": [],
+        "data": "d2",
+        "id": 4,
+        "name": "monkey",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+
+    z.close()
+  })
+
+  it('queryComplete promise', async () => {
+    const { z } = await setupTestEnvironment(simpleSchema)
+    await z.mutate.table.insert({ a: 1, b: 'a' })
+    await z.mutate.table.insert({ a: 2, b: 'b' })
+
+    const queryCompleteResolver = resolver<true>()
+
+    const onTransactionCommit = () => {}
+
+    const query = z.query.table
+    const view = query.materialize((_, input) => {
+      return new VueView(
+        input,
+        onTransactionCommit,
+        { singular: false, relationships: {} },
+        () => {},
+        queryCompleteResolver.promise,
+      )
+    })
+
+    expect(view.data).toMatchInlineSnapshot(`
+    [
+      {
+        "a": 1,
+        "b": "a",
+        Symbol(rc): 1,
+      },
+      {
+        "a": 2,
+        "b": "b",
+        Symbol(rc): 1,
+      },
+    ]
+  `)
+    expect(view.status).toEqual('unknown')
+
+    queryCompleteResolver.resolve(true)
+    await 1
+    expect(view.status).toEqual('complete')
+
+    z.close()
+  })
+})
+
+describe('vueViewFactory', () => {
+  interface TestReturn {
+    a: number
+    b: string
+  }
+
+  it('correctly calls corresponding handlers', async () => {
+    const { z } = await setupTestEnvironment(simpleSchema)
+    await z.mutate.table.insert({ a: 1, b: 'a' })
+    await z.mutate.table.insert({ a: 2, b: 'b' })
+
+    const onDestroy = vi.fn()
+    const onTransactionCommit = vi.fn()
+
+    const query = z.query.table
+    const view = query.materialize((_, input) => {
+      return vueViewFactory(
+        undefined as unknown as Query<typeof simpleSchema, 'table', TestReturn>,
+        input,
+        { singular: false, relationships: {} },
+        onDestroy,
+        onTransactionCommit,
+        true,
+      )
+    })
+
+    expect(view).toBeDefined()
+    expect(onTransactionCommit).not.toHaveBeenCalled()
+    expect(onDestroy).not.toHaveBeenCalled()
+    view.destroy()
+    expect(onDestroy).toHaveBeenCalledTimes(1)
+
+    z.close()
+  })
+})
diff --git a/src/view.ts b/src/view.ts
index ecd0ebf..c4d0dbe 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -1,9 +1,19 @@
 // based on https://github.com/rocicorp/mono/tree/main/packages/zero-solid
 
-import type { ResultType, Schema } from '@rocicorp/zero'
-import type { Change, Entry, Format, HumanReadable, Input, Output, Query, ViewFactory } from '@rocicorp/zero/advanced'
-import { applyChange } from '@rocicorp/zero/advanced'
-import { reactive, toRaw } from 'vue'
+import type {
+  Change,
+  Entry,
+  Format,
+  HumanReadable,
+  Input,
+  Output,
+  Query,
+  ResultType,
+  Schema,
+  ViewFactory,
+} from '@rocicorp/zero'
+import { applyChange } from '@rocicorp/zero'
+import { reactive } from 'vue'
 
 interface QueryResultDetails {
   readonly type: ResultType
@@ -14,33 +24,10 @@ type State = [Entry, QueryResultDetails]
 const complete = { type: 'complete' } as const
 const unknown = { type: 'unknown' } as const
 
-interface RefCountMap {
-  get: (entry: Entry) => number | undefined
-  set: (entry: Entry, refCount: number) => void
-  delete: (entry: Entry) => boolean
-}
-
-class VueRefCountMap implements RefCountMap {
-  readonly #map = new WeakMap<Entry, number>()
-
-  get(entry: Entry) {
-    return this.#map.get(toRaw(entry))
-  }
-
-  set(entry: Entry, refCount: number) {
-    this.#map.set(toRaw(entry), refCount)
-  }
-
-  delete(entry: Entry) {
-    return this.#map.delete(toRaw(entry))
-  }
-}
-
-class VueView<V> implements Output {
+export class VueView<V> implements Output {
   readonly #input: Input
   readonly #format: Format
   readonly #onDestroy: () => void
-  readonly #refCountMap = new VueRefCountMap()
 
   #state: State
 
@@ -90,7 +77,6 @@ class VueView<V> implements Output {
       this.#input.getSchema(),
       '',
       this.#format,
-      this.#refCountMap,
     )
   }
 
diff --git a/test/index.test.ts b/test/index.test.ts
index 10214ff..5be2cd1 100644
--- a/test/index.test.ts
+++ b/test/index.test.ts
@@ -12,7 +12,7 @@ describe('zero-vue', () => {
       })
       .primaryKey('id')
 
-    const schema = createSchema(1, {
+    const schema = createSchema({
       tables: [user],
     })
 
@@ -34,6 +34,14 @@ describe('zero-vue', () => {
     expect(users.value).toEqual([])
     await new Promise(resolve => setTimeout(resolve, 0))
 
-    expect(users.value).toEqual([{ id: 'asdf', name: 'Alice' }])
+    expect(users.value).toMatchInlineSnapshot(`
+        [
+          {
+            "id": "asdf",
+            "name": "Alice",
+            Symbol(rc): 1,
+          },
+        ]
+    `)
   })
 })


Reviews:
user: login: arv
body: LGTM

Thanks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T15:14:15Z
user: login: maxstevens-nl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T23:27:54Z

Review comments:
diff_hunk: @@ -1,10 +1,19 @@
 // based on https://github.com/rocicorp/mono/tree/main/packages/zero-solid
 
-import type { ResultType, Schema } from '@rocicorp/zero'
-import type { AdvancedQuery, HumanReadable, Query } from '@rocicorp/zero/advanced'
+import type { HumanReadable, Query, ResultType, Schema } from '@rocicorp/zero'
+import type { UseQueryOptions } from '@rocicorp/zero/solid'
path: src/query.ts
user: login: arv
body: I think you should include TTL instead and:

```ts
export type UseQueryOptions = {
  ttl?: TTL | undefined;
};
```
created_at: 2025-04-08T15:11:34Z
author_association: COLLABORATOR
diff_hunk: @@ -1,10 +1,19 @@
 // based on https://github.com/rocicorp/mono/tree/main/packages/zero-solid
 
-import type { ResultType, Schema } from '@rocicorp/zero'
-import type { AdvancedQuery, HumanReadable, Query } from '@rocicorp/zero/advanced'
+import type { HumanReadable, Query, ResultType, Schema } from '@rocicorp/zero'
+import type { UseQueryOptions } from '@rocicorp/zero/solid'
path: src/query.ts
user: login: maxstevens-nl
body: Fair enough, fixed
created_at: 2025-04-08T23:27:54Z
author_association: COLLABORATOR

PR comments:
user: login: socket-security[bot]
created_at: 2025-04-04T09:43:16Z
body: **New, updated, and removed dependencies detected.** Learn more about [Socket for GitHub ↗︎](https://socket.dev?utm_medium=gh)


| Package | New capabilities | Transitives | Size | Publisher |
|:--- |:--- |:--- |:--- |:--- |
| [npm/@rocicorp/zero@0.17.2025030701](https://socket.dev/npm/package/@rocicorp/zero/overview/0.17.2025030701) ➜ [0.18.2025040300](https://socket.dev/npm/package/@rocicorp/zero/overview/0.18.2025040300) | Transitive: <a href="https://socket.dev/npm/package/@rocicorp/zero/alerts/0.18.2025040300?tab=dependencies&alert_name=usesEval">eval</a>, <a href="https://socket.dev/npm/package/@rocicorp/zero/alerts/0.18.2025040300?tab=dependencies&alert_name=filesystemAccess">filesystem</a>, <a href="https://socket.dev/npm/package/@rocicorp/zero/alerts/0.18.2025040300?tab=dependencies&alert_name=shellAccess">shell</a>, <a href="https://socket.dev/npm/package/@rocicorp/zero/alerts/0.18.2025040300?tab=dependencies">unsafe</a> | <a href="https://socket.dev/npm/package/@rocicorp/zero/dependencies/0.18.2025040300">`+240`</a> | 63 MB | <a href="https://socket.dev/npm/user/aboodman">aboodman</a> |



[**View full report↗︎**](https://socket.dev/dashboard/org/danielroe/diff/0e8eea8a-a307-4dd7-993a-7b3c5a99dc2f/4a9ebeb0-be6d-41a2-9438-c8a5b630b0a6)

<!-- overview-comment -->
author_association: NONE
user: login: arv
created_at: 2025-04-08T15:05:56Z
body: Feel free to add me as a reviewer when you do these things
author_association: COLLABORATOR
user: login: maxstevens-nl
created_at: 2025-04-08T23:31:38Z
body: @arv @danielroe this should be ready to merge now
author_association: COLLABORATOR
user: login: danielroe
created_at: 2025-04-08T23:34:04Z
body: apologies! please do ping me whenever you want me to see; I don't always get notified when a PR is opened (will need to somehow fix the github notifications 🤔)
author_association: OWNER