1"use strict";(globalThis.webpackChunkstorm_docs=globalThis.webpackChunkstorm_docs||[]).push([[2436],{90845(e,r,n){n.r(r),n.d(r,{assets:()=>d,contentTitle:()=>c,default:()=>p,frontMatter:()=>a,metadata:()=>s,toc:()=>u});const s=JSON.parse('{"id":"cursors","title":"Cursor Serialization","description":"This page covers the low-level details of cursor serialization for scrolling. For a high-level introduction to scrolling, see Pagination and Scrolling: Scrolling.","source":"@site/versioned_docs/version-1.11.0/cursors.md","sourceDirName":".","slug":"/cursors","permalink":"/docs/1.11.0/cursors","draft":false,"unlisted":false,"editUrl":"https://github.com/storm-orm/storm-framework/edit/main/docs/cursors.md","tags":[],"version":"1.11.0","frontMatter":{},"sidebar":"docs","previous":{"title":"Entity Cache","permalink":"/docs/1.11.0/entity-cache"},"next":{"title":"Configuration","permalink":"/docs/1.11.0/configuration"}}');var t=n(74848),i=n(28453),o=n(4865),l=n(19365);const a={},c="Cursor Serialization",d={},u=[{value:"Overview",id:"overview",level:2},{value:"Cursor format",id:"cursor-format",level:2},{value:"Security",id:"security",level:2},{value:"Supported types",id:"supported-types",level:2},{value:"Custom cursor codecs",id:"custom-cursor-codecs",level:2},{value:"Step 1: Implement the codec",id:"step-1-implement-the-codec",level:3},{value:"Step 2: Register the provider",id:"step-2-register-the-provider",level:3},{value:"Constraints",id:"constraints",level:3},{value:"Size limit",id:"size-limit",level:2}];function h(e){const r={a:"a",code:"code",h1:"h1",h2:"h2",h3:"h3",header:"header",li:"li",p:"p",pre:"pre",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,i.R)(),...e.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(r.header,{children:(0,t.jsx)(r.h1,{id:"cursor-serialization",children:"Cursor Serialization"})}),"\n",(0,t.jsxs)(r.p,{children:["This page covers the low-level details of cursor serialization for scrolling. For a high-level introduction to scrolling, see ",(0,t.jsx)(r.a,{href:"/docs/1.11.0/pagination-and-scrolling#scrolling",children:"Pagination and Scrolling: Scrolling"}),"."]}),"\n",(0,t.jsx)(r.h2,{id:"overview",children:"Overview"}),"\n",(0,t.jsxs)(r.p,{children:["When a ",(0,t.jsx)(r.code,{children:"Window"})," is returned from a scroll operation, it carries ",(0,t.jsx)(r.code,{children:"Scrollable"})," navigation tokens that encode the cursor position. These tokens can be serialized to opaque, URL-safe strings using ",(0,t.jsx)(r.code,{children:"toCursor()"})," and deserialized using ",(0,t.jsx)(r.code,{children:"Scrollable.fromCursor()"}),". This allows REST APIs to pass scroll state as a query parameter between requests."]}),"\n",(0,t.jsxs)(o.A,{groupId:"language",children:[(0,t.jsx)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:(0,t.jsx)(r.pre,{children:(0,t.jsx)(r.code,{className:"language-kotlin",children:"// Server: serialize cursor into response\nval cursor: String? = window.nextCursor()\n\n// Client sends cursor back in next request\n// Server: reconstruct scrollable\ncursor?.let {\n val scrollable = Scrollable.fromCursor(User_.id, it)\n val next = userRepository.scroll(scrollable)\n}\n"})})}),(0,t.jsx)(l.A,{value:"java",label:"Java",children:(0,t.jsx)(r.pre,{children:(0,t.jsx)(r.code,{className:"language-java",children:"// Server: serialize cursor into response\nString cursor = window.nextCursor();\n\n// Client sends cursor string back in next request\n// Server: reconstruct scrollable\nvar scrollable = Scrollable.fromCursor(User_.id, cursor);\nvar next = userRepository.scroll(scrollable);\n"})})})]}),"\n",(0,t.jsx)(r.h2,{id:"cursor-format",children:"Cursor format"}),"\n",(0,t.jsx)(r.p,{children:"The serialized cursor is a Base64 URL-safe encoded binary payload. The format is intentionally opaque: clients should treat it as an immutable token and never parse or modify it. The internal structure includes:"}),"\n",(0,t.jsxs)(r.ul,{children:["\n",(0,t.jsx)(r.li,{children:"A version byte for forward compatibility"}),"\n",(0,t.jsx)(r.li,{children:"Fingerprints of the metamodel key/sort paths and the codec registry, used to detect mismatches on deserialization"}),"\n",(0,t.jsx)(r.li,{children:"The scroll direction (forward or backward)"}),"\n",(0,t.jsx)(r.li,{children:"The page size"}),"\n",(0,t.jsx)(r.li,{children:"The cursor value(s) for the key and optional sort fields"}),"\n"]}),"\n",(0,t.jsx)(r.p,{children:"Cursors produced by one application instance can be consumed by another, as long as both use the same entity model and codec registry. A cursor becomes invalid if the metamodel paths change (for example, renaming the key field) or if the codec registry changes (for example, adding or removing a custom codec)."}),"\n",(0,t.jsx)(r.h2,{id:"security",children:"Security"}),"\n",(0,t.jsxs)(r.p,{children:["The cursor format is opaque but ",(0,t.jsx)(r.strong,{children:"not tamper-proof"}),". A malicious client can decode the Base64 payload, modify cursor values, and re-encode it. Storm validates structural integrity (version, fingerprints, type tags, trailing bytes), but it does not detect value tampering."]}),"\n",(0,t.jsx)(r.p,{children:"If your cursors are exposed to untrusted clients (for example, in a public REST API), consider one of the following mitigations:"}),"\n",(0,t.jsxs)(r.ul,{children:["\n",(0,t.jsxs)(r.li,{children:[(0,t.jsx)(r.strong,{children:"HMAC wrapping."})," Sign the cursor string with a server-side secret and verify the signature before passing it to ",(0,t.jsx)(r.code,{children:"fromCurs
1or()"}),". This prevents modification without detection."]}),"\n",(0,t.jsxs)(r.li,{children:[(0,t.jsx)(r.strong,{children:"Encryption."})," Encrypt the cursor string before sending it to the client and decrypt it on the server. This prevents both reading and modification."]}),"\n",(0,t.jsxs)(r.li,{children:[(0,t.jsx)(r.strong,{children:"Server-side storage."})," Store the cursor state on the server (for example, in a session or cache) and give the client an opaque session key instead of the actual cursor."]}),"\n"]}),"\n",(0,t.jsx)(r.p,{children:"Storm does not provide built-in signing or encryption because the appropriate security mechanism depends on your application's threat model and infrastructure."}),"\n",(0,t.jsx)(r.h2,{id:"supported-types",children:"Supported types"}),"\n",(0,t.jsx)(r.p,{children:"The following Java types can be used as cursor values (key or sort fields) out of the box:"}),"\n",(0,t.jsxs)(r.table,{children:[(0,t.jsx)(r.thead,{children:(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.th,{children:"Type"}),(0,t.jsx)(r.th,{children:"Binary size"}),(0,t.jsx)(r.th,{children:"Notes"})]})}),(0,t.jsxs)(r.tbody,{children:[(0,t.jsxs)(r.tr,{children:[(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"Integer"})," / ",(0,t.jsx)(r.code,{children:"int"})]}),(0,t.jsx)(r.td,{children:"4 bytes"}),(0,t.jsx)(r.td,{})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"Long"})," / ",(0,t.jsx)(r.code,{children:"long"})]}),(0,t.jsx)(r.td,{children:"8 bytes"}),(0,t.jsx)(r.td,{})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"Short"})," / ",(0,t.jsx)(r.code,{children:"short"})]}),(0,t.jsx)(r.td,{children:"2 bytes"}),(0,t.jsx)(r.td,{})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"Byte"})," / ",(0,t.jsx)(r.code,{children:"byte"})]}),(0,t.jsx)(r.td,{children:"1 byte"}),(0,t.jsx)(r.td,{})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsxs)(r.td,{children:[(0,t.jsx)(r.code,{children:"Boolean"})," / ",(0,t.jsx)(r.code,{children:"boolean"})]}),(0,t.jsx)(r.td,{children:"1 byte"}),(0,t.jsx)(r.td,{})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"String"})}),(0,t.jsx)(r.td,{children:"4 + length"}),(0,t.jsx)(r.td,{children:"UTF-8 encoded"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"UUID"})}),(0,t.jsx)(r.td,{children:"16 bytes"}),(0,t.jsx)(r.td,{})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"Instant"})}),(0,t.jsx)(r.td,{children:"12 bytes"}),(0,t.jsx)(r.td,{children:"Epoch seconds + nanos"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"LocalDate"})}),(0,t.jsx)(r.td,{children:"6 bytes"}),(0,t.jsx)(r.td,{children:"Year (4) + month (1) + day (1)"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"LocalDateTime"})}),(0,t.jsx)(r.td,{children:"11 bytes"}),(0,t.jsx)(r.td,{children:"Date (6) + hour/min/sec (3) + nanos (4)"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"OffsetDateTime"})}),(0,t.jsx)(r.td,{children:"15 bytes"}),(0,t.jsx)(r.td,{children:"LocalDateTime (11) + offset seconds (4)"})]}),(0,t.jsxs)(r.tr,{children:[(0,t.jsx)(r.td,{children:(0,t.jsx)(r.code,{children:"BigDecimal"})}),(0,t.jsx)(r.td,{children:"4 + length"}),(0,t.jsx)(r.td,{children:"Serialized as plain string"})]})]})]}),"\n",(0,t.jsxs)(r.p,{children:["If your key or sort field uses a type not in this list, serialization via ",(0,t.jsx)(r.code,{children:"toCursor()"})," will throw an ",(0,t.jsx)(r.code,{children:"IllegalStateException"}),". You can either use one of the supported types for your key/sort columns, or register a custom codec."]}),"\n",(0,t.jsxs)(r.p,{children:["Note that in-memory navigation (using ",(0,t.jsx)(r.code,{children:"nextScrollable()"})," and ",(0,t.jsx)(r.code,{children:"previousScrollable()"})," directly, without serializing to a cursor string) works with any type, including inline records and other composite types. The type restriction only applies to ",(0,t.jsx)(r.code,{children:"toCursor()"})," serialization."]}),"\n",(0,t.jsx)(r.h2,{id:"custom-cursor-codecs",children:"Custom cursor codecs"}),"\n",(0,t.jsxs)(r.p,{children:["To add cursor serialization support for a custom type, implement the ",(0,t.jsx)(r.code,{children:"CursorCodecProvider"})," SPI. Storm discovers providers via ",(0,t.jsx)(r.code,{children:"ServiceLoader"}),"."]}),"\n",(0,t.jsx)(r.h3,{id:"step-1-implement-the-codec",children:"Step 1: Implement the codec"}),"\n",(0,t.jsxs)(r.p,{children:["Create a class that implements ",(0,t.jsx)(r.code,{children:"CursorCodecProvider"})," and returns codec entries for your custom types. Each entry binds a unique tag (in the range 64-255), a Java type, and a ",(0,t.jsx)(r.code,{children:"CursorCodec"})," implementation. Tags below 64 are reserved for built-in types and will be rejected at startup."]}),"\n",(0,t.jsxs)(o.A,{groupId:"language",children:[(0,t.jsx)(l.A,{value:"kotlin",label:"Kotlin",default:!0,children:(0,t.jsx)(r.pre,{children:(0,t.jsx)(r.code,{className:"language-kotlin",children:"class MyCursorCodecProvider : CursorCodecProvider {\n override fun codecs(): List<CursorCodecEntry<*>> = listOf(\n CursorCodecEntry(64, UserId::class.java, object : CursorCodec<UserId> {\n override fun write(out: DataOutputStream, value: UserId) {\n out.writeLong(value.value)\n }\n\n override fun read(`in`: DataInputStream): UserId {\n return UserId(`in`.readLong())\n }\n })\n )\n}\n"})})}),(0,t.jsx)(l.A,{value:"java",label:"Java",children:(0,t.jsx)(r.pre,{children:(0,t.jsx)(r.code,{className:"language-java",children:"public class MyCursorCodecProvider implements CursorCodecProvider {\n @Override\n public List<CursorCodecEntry<?>> codecs() {\n return List.of(\n new CursorCodecEntry<>(64, UserId.class, new CursorCodec<UserId>() {\n @Override\n public void write(DataOutputStream out, UserId value) throws IOException {\n out.writeLong(value.value());\n }\n\n @Override\n public UserId read(DataInputStream in) throws IOException {\n return new UserId(in.readLong());\n }\n })\n );\n }\n}\n"})})})]}),"\n",(0,t.jsx)(r.h3,{id:"step-2-register-the-provider",children:"Step 2: Register the provider"}),"\n",(0,t.jsxs)(r.p,{children:["Create a service file at ",(0,t.jsx)(r.code,{children:"META-INF/services/st.orm.core.spi.CursorCodecProvider"})," containing the fully qualified class name of your provider:"]}),"\n",(0,t.jsx)(r.pre,{children:(0,t.jsx)(r.code,{children:"com.example.MyCursorCodecProvider\n"})}),"\n",(0,t.jsx)(r.h3,{id:"constraints",children:"Constraints"}),"\n",(0,t.jsxs)(r.ul,{children:["\n",(0,t.jsxs)(r.li,{children:["Custom tags must be in the range ",(0,t.jsx)(r.strong,{children:"64-255"}),". Tags 0-63 are reserved for built-in types. Using a reserved tag throws an ",(0,t.jsx)(r.code,{children:"IllegalArgumentException"})," at startup."]}),"\n",(0,t.jsxs)(r.li,{children:["Each tag and each type can only be registered once. Duplicate registrations throw an ",(0,t.jsx)(r.code,{children:"IllegalArgumentException"})," at startup."]}),"\n",(0,t.jsx)(r.li,{children:"The codec registry is built once at class load time. Adding or removing codecs changes the registry fingerprint, which invalidates all previously serialized cursors."}),"\n",(0,t.jsxs)(r.li,{children:["The ",(0,t.jsx)(r.code,{children:"write"})," method receives a non-null value; null handling is done by the framework. The ",(0,t.jsx)(r.code,{children:"read"})," method must return a non-null value."]}),"\n"]}),"\n",(0,t.jsx)(r.h2,{id:"size-limit",children:"Size limit"}),"\n",(0,t.jsxs)(r.p,{children:["Cursor strings carry a page size that is validated during deserialization. The maximum size defaults to 1000 and can be configured via the ",(0,t.jsx)(r.code,{children:"st.orm.scrollable.maxSize"})," system property. This limit only applies to cursors deserialized from external input via ",(0,t.jsx)(r.code,{children:"fromCurs
1or()"}),", not to programmatic ",(0,t.jsx)(r.code,{children:"Scrollable.of()"})," calls."]})]})}function p(e={}){const{wrapper:r}={...(0,i.R)(),...e.components};return r?(0,t.jsx)(r,{...e,children:(0,t.jsx)(h,{...e})}):h(e)}},19365(e,r,n){n.d(r,{A:()=>a});n(96540);var s=n(34164),t=n(47751);const i="tabItem_Ymn6";var o=n(74848);function l({children:e,className:r,hidden:n}){return(0,o.jsx)("div",{role:"tabpanel",className:(0,s.A)(i,r),hidden:n,children:e})}function a({children:e,className:r,value:n}){const{selectedValue:s,lazy:i}=(0,t.uc)(),a=n===s;return!a&&i?null:(0,o.jsx)(l,{className:r,hidden:!a,children:e})}},4865(e,r,n){n.d(r,{A:()=>x});n(96540);var s=n(34164),t=n(17559),i=n(47751),o=n(23104),l=n(92303);const a="tabList__CuJ",c="tabItem_LNqP";var d=n(74848);function u({className:e}){const{selectedValue:r,selectValue:n,tabValues:t,block:l}=(0,i.uc)(),a=[],{blockElementScrollPositionUntilNextRender:u}=(0,o.a_)(),h=e=>{const s=e.currentTarget,i=a.indexOf(s),o=t[i].value;o!==r&&(u(s),n(o))},p=e=>{let r=null;switch(e.key){case"Enter":h(e);break;case"ArrowRight":{const n=a.indexOf(e.currentTarget)+1;r=a[n]??a[0];break}case"ArrowLeft":{const n=a.indexOf(e.currentTarget)-1;r=a[n]??a[a.length-1];break}}r?.focus()};return(0,d.jsx)("ul",{role:"tablist","aria-orientation":"horizontal",className:(0,s.A)("tabs",{"tabs--block":l},e),children:t.map(({value:e,label:n,attributes:t})=>(0,d.jsx)("li",{role:"tab",tabIndex:r===e?0:-1,"aria-selected":r===e,ref:e=>{a.push(e)},onKeyDown:p,onClick:h,...t,className:(0,s.A)("tabs__item",c,t?.className,{"tabs__item--active":r===e}),children:n??e},e))})}function h({children:e}){return(0,d.jsx)("div",{className:"margin-top--md",children:e})}function p({className:e,children:r}){return(0,d.jsxs)("div",{className:(0,s.A)(t.G.tabs.container,"tabs-container",a),children:[(0,d.jsx)(u,{className:e}),(0,d.jsx)(h,{children:r})]})}function x(e){const r=(0,l.A)(),n=(0,i.OC)(e);return(0,d.jsx)(i.O_,{value:n,children:(0,d.jsx)(p,{className:e.className,children:(0,i.vT)(e.children)})},String(r))}},47751(e,r,n){n.d(r,{OC:()=>x,O_:()=>v,uc:()=>j,vT:()=>d});var s=n(96540),t=n(56347),i=n(205),o=n(57485),l=n(70679),a=n(31682),c=n(74848);function d(e){return s.Children.toArray(e).filter(e=>"\n"!==e)}function u(e){const{values:r,children:n}=e;return(0,s.useMemo)(()=>{const e=r??function(e){return s.Children.toArray(e).flatMap(e=>{if(!e)return[];if((0,s.isValidElement)(e)&&function(e){const{props:r}=e;return!!r&&"object"==typeof r&&"value"in r}(e))return[e];const r="string"==typeof e.type?e.type:e.type.name;throw new Error(`Docusaurus error: Bad <Tabs> child <${r}>: all children of the <Tabs> component should be <TabItem>, and every <TabItem> should have a unique "value" prop.\nIf you do not want to pass on a "value" prop to the direct children of <Tabs>, you can also pass an explicit <Tabs values={...}> prop.`)}).map(({props:{value:e,label:r,attributes:n,default:s}})=>({value:e,label:r,attributes:n,default:s}))}(n);return function(e){const r=(0,a.XI)(e,(e,r)=>e.value===r.value);if(r.length>0)throw new Error(`Docusaurus error: Duplicate values "${r.map(e=>`'${e.value}'`).join(", ")}" found in <Tabs>. Every value needs to be unique.`)}(e),e},[r,n])}function h({value:e,tabValues:r}){return r.some(r=>r.value===e)}function p({queryString:e=!1,groupId:r}){const n=(0,t.W6)(),i=function({queryString:e=!1,groupId:r}){if("string"==typeof e)return e;if(!1===e)return null;if(!0===e&&!r)throw new Error('Docusaurus error: The <Tabs> component groupId prop is required if queryString=true, because this value is used as the search param name. You can also provide an explicit value such as queryString="my-search-param".');return r??null}({queryString:e,groupId:r});return[(0,o.aZ)(i),(0,s.useCallback)(e=>{if(!i)return;const r=new URLSearchParams(n.location.search);r.set(i,e),n.replace({...n.location,search:r.toString()})},[i,n])]}function x(e){const{defaultValue:r,queryString:n=!1,groupId:t}=e,o=u(e),[a,c]=(0,s.useState)(()=>function({defaultValue:e,tabValues:r}){if(0===r.length)throw new Error("Docusaurus error: the <Tabs> component requires at least one <TabItem> children component");if(e){if(!h({value:e,tabValues:r}))throw new Error(`Docusaurus error: The <Tabs> has a defaultValue "${e}" but none of its children has the corresponding value. Available values are: ${r.map(e=>e.value).join(", ")}. If you intend to show no default tab, use defaultValue={null} instead.`);return e}const n=r.find(e=>e.default)??r[0];if(!n)throw new Error("Unexpected error: 0 tabValues");return n.value}({defaultValue:r,tabValues:o})),[d,x]=p({queryString:n,groupId:t}),[m,j]=function({groupId:e}){const r=function(e){return e?`docusaurus.tab.${e}`:null}(e),[n,t]=(0,l.Dv)(r);return[n,(0,s.useCallback)(e=>{r&&t.set(e)},[r,t])]}({groupId:t}),v=(()=>{const e=d??m;return h({value:e,tabValues:o})?e:null})();(0,i.A)(()=>{v&&c(v)},[v]);return{selectedValue:a,selectValue:(0,s.useCallback)(e=>{if(!h({value:e,tabValues:o}))throw new Error(`Can't select invalid tab value=${e}`);c(e),x(e),j(e)},[x,j,o]),tabValues:o,lazy:e.lazy??!1,block:e.block??!1}}const m=(0,s.createContext)(null);function j(){const e=s.useContext(m);if(!e)throw new Error("useTabsContext() must be used within a Tabs component");return e}function v(e){return(0,c.jsx)(m.Provider,{value:e.value,children:e.children})}},28453(e,r,n){n.d(r,{R:()=>o,x:()=>l});var s=n(96540);const t={},i=s.createContext(t);function o(e){const r=s.useContext(i);return s.useMemo(function(){return"function"==typeof e?e(r):{...r,...e}},[r,e])}function l(e){let r;return r=e.disableParentContext?"function"==typeof e.components?e.components(t):e.components||t:o(e.components),s.createElement(i.Provider,{value:r},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.